From cc8c90204ea59d3ec426874f9890d6ab2edba450 Mon Sep 17 00:00:00 2001
From: Philipp Wagner
Date: Sun, 4 Oct 2026 14:15:56 +0200
Subject: [PATCH] Draw the whole interface in Compose
Etappen 3 to 6a of the migration (129 commits). Every window the operator sees
is Compose; the JavaFX ones still exist beside them so the two could be
compared against a live session, which is the only way the differences came to
light.
The toolchain first, then the settings window with its eleven tabs and 72 real
preferences; then the multi-column table as a component, and with it the rules
it needs -- stable row identity, a selection that survives a refresh, column
order the operator can drag, and a repaint when the rows change rather than
when the list instance does. Then the update and monitor windows, and the main
window: 22 station columns, the filter bar, the selected-station panel, the
message tabs, the priority bar, the sked controls, the status indicators and
the timeline, assembled behind a splitter because Compose has none.
The map stops being a WebView. A Compose canvas projects WebMercator tiles
itself, with the clustering in core and an asynchronous fetcher in place of the
local tile proxy; derive() becomes a pure Kotlin reimplementation instead of a
reflection call into com.sun.javafx.
Most of the volume here is parity the operator would notice rather than new
behaviour: desktop density instead of Material's phone minimums, colours taken
from the JavaFX stylesheets instead of Material's palette, row dividers, band
columns, grid-square labels and their colours, the home marker and antenna
beam, pinch-to-zoom under Wayland, and layouts that survive a window narrower
than the chart they stack.
Two lessons are worth more than the diff. A control bound to a value Compose
cannot observe writes through and never redraws -- that one shipped repeatedly
with a green build, because no test drew anything. And a layout that clips
instead of wrapping is worse than one that is merely too narrow: half a word
is the worst available outcome.
---
.github/workflows/nightly-artifacts.yml | 133 +-
.github/workflows/tagged-release.yml | 133 +-
.gitignore | 6 +
CLAUDE.md | 2 +-
app-desktop/build.gradle.kts | 120 +-
.../kst4contest/view/CommandLineOptions.java | 35 +-
.../view/ComposeChatInputActions.java | 99 +
.../kst4contest/view/ComposeMenuActions.java | 136 +
.../main/java/kst4contest/view/GuiUtils.java | 2 +-
.../view/Kst4ContestApplication.java | 4560 +++++------------
.../view/OperatorProfilePickerDialog.java | 145 -
.../view/OperatorProfileSettingsPane.java | 488 --
.../kst4contest/view/map/StationMapView.java | 4 +-
.../main/kotlin/kst4contest/view/BuildInfo.kt | 18 +
.../kst4contest/view/compose/AirplaneCell.kt | 61 +
.../kst4contest/view/compose/ChatInputRow.kt | 308 ++
.../view/compose/ChatInputState.kt | 199 +
.../view/compose/ComposeWindowHost.kt | 185 +
.../kst4contest/view/compose/ConnectionBar.kt | 96 +
.../kst4contest/view/compose/DataTable.kt | 381 ++
.../view/compose/DataTableState.kt | 383 ++
.../kst4contest/view/compose/DataTableView.kt | 440 ++
.../kst4contest/view/compose/Density.kt | 68 +
.../view/compose/DirectedMessageColumns.kt | 88 +
.../view/compose/DirectedMessageRowStyle.kt | 68 +
.../kst4contest/view/compose/FormControls.kt | 564 ++
.../view/compose/JavaFxStylesheet.kt | 353 ++
.../kst4contest/view/compose/MainMenuBar.kt | 137 +
.../kst4contest/view/compose/MainMenuState.kt | 67 +
.../kst4contest/view/compose/MainWindow.kt | 253 +
.../view/compose/MainWindowFrame.kt | 75 +
.../view/compose/MainWindowHost.kt | 112 +
.../view/compose/MainWindowState.kt | 157 +
.../view/compose/MessageFormats.kt | 52 +
.../kst4contest/view/compose/MessageTabs.kt | 90 +
.../view/compose/MonitorColumns.kt | 72 +
.../kst4contest/view/compose/MonitorWindow.kt | 95 +
.../kst4contest/view/compose/ObservedValue.kt | 37 +
.../compose/OperatorProfilePickerWindow.kt | 205 +
.../view/compose/PublicMessageColumns.kt | 50 +
.../compose/SelectedStationMessageColumns.kt | 60 +
.../view/compose/SelectedStationPanel.kt | 252 +
.../view/compose/SelectedStationState.kt | 183 +
.../kst4contest/view/compose/SettingsTabs.kt | 287 ++
.../view/compose/SettingsWindow.kt | 302 ++
.../view/compose/SnippetContextMenu.kt | 30 +
.../kst4contest/view/compose/SplitterPane.kt | 136 +
.../kst4contest/view/compose/SplitterState.kt | 109 +
.../view/compose/StationColumns.kt | 154 +
.../view/compose/StationFilterBar.kt | 279 +
.../view/compose/StationFilterState.kt | 161 +
.../view/compose/StationRowStyle.kt | 42 +
.../kst4contest/view/compose/StatusBar.kt | 242 +
.../view/compose/StatusIndicatorState.kt | 225 +
.../kotlin/kst4contest/view/compose/Theme.kt | 157 +
.../kst4contest/view/compose/TimelineState.kt | 212 +
.../kst4contest/view/compose/TimelineView.kt | 311 ++
.../view/compose/TopPriorityState.kt | 44 +
.../kst4contest/view/compose/UpdateWindow.kt | 153 +
.../view/compose/UpdateWindowState.kt | 96 +
.../view/compose/WindowShortcuts.kt | 70 +
.../view/compose/map/ComposeStationMap.kt | 916 ++++
.../view/compose/map/MapOverlayControls.kt | 116 +
.../view/compose/map/PathAnalysisDetails.kt | 148 +
.../view/compose/map/PathProfileChart.kt | 573 +++
.../view/compose/map/StationMapWindow.kt | 364 ++
.../view/compose/map/TileBitmapCache.kt | 54 +
.../view/compose/tabs/AirscoutTab.kt | 92 +
.../view/compose/tabs/AirscoutTabState.kt | 111 +
.../view/compose/tabs/BeaconTab.kt | 55 +
.../view/compose/tabs/BeaconTabState.kt | 104 +
.../compose/tabs/ExternalInterfaceFields.kt | 156 +
.../view/compose/tabs/GuiOptionsTab.kt | 53 +
.../view/compose/tabs/GuiOptionsTabState.kt | 41 +
.../view/compose/tabs/LogSynchTab.kt | 99 +
.../view/compose/tabs/LogSynchTabState.kt | 127 +
.../view/compose/tabs/MessageHandlingTab.kt | 30 +
.../compose/tabs/MessageHandlingTabState.kt | 36 +
.../view/compose/tabs/NotificationTab.kt | 148 +
.../view/compose/tabs/NotificationTabState.kt | 214 +
.../view/compose/tabs/ProfilesTab.kt | 378 ++
.../view/compose/tabs/ProfilesTabState.kt | 246 +
.../view/compose/tabs/ShortcutsTab.kt | 45 +
.../view/compose/tabs/ShortcutsTabState.kt | 51 +
.../view/compose/tabs/StationTab.kt | 103 +
.../view/compose/tabs/StationTabState.kt | 213 +
.../view/compose/tabs/TrxSynchTab.kt | 67 +
.../view/compose/tabs/TrxSynchTabState.kt | 76 +
.../view/compose/tabs/WorkedDatabaseTab.kt | 150 +
.../compose/tabs/WorkedDatabaseTabState.kt | 96 +
.../view/ComposeMainWindowOptionTest.java | 52 +
.../kotlin/kst4contest/view/BuildInfoTest.kt | 24 +
.../view/compose/AirplaneCellTest.kt | 107 +
.../view/compose/ChatInputStateTest.kt | 435 ++
.../view/compose/DataTableStateTest.kt | 559 ++
.../compose/DirectedMessageColumnsTest.kt | 123 +
.../compose/DirectedMessageRowStyleTest.kt | 75 +
.../view/compose/EditableListStateTest.kt | 220 +
.../view/compose/JavaFxStylesheetTest.kt | 186 +
.../view/compose/MainMenuStateTest.kt | 99 +
.../view/compose/MainWindowFrameTest.kt | 121 +
.../view/compose/MainWindowStateTest.kt | 173 +
.../view/compose/MonitorColumnsTest.kt | 133 +
.../view/compose/ObservedValueTest.kt | 93 +
.../compose/OperatorProfilePickerStateTest.kt | 69 +
.../view/compose/PublicMessageColumnsTest.kt | 70 +
.../view/compose/SelectedStationStateTest.kt | 226 +
.../view/compose/SettingsFieldCoverageTest.kt | 44 +
.../SettingsForeignWriteCoverageTest.kt | 50 +
.../view/compose/SplitterStateTest.kt | 225 +
.../view/compose/StationColumnsTest.kt | 136 +
.../view/compose/StationFilterStateTest.kt | 157 +
.../view/compose/StatusIndicatorStateTest.kt | 414 ++
.../view/compose/TimelineStateTest.kt | 316 ++
.../view/compose/TopPriorityStateTest.kt | 122 +
.../view/compose/TypographyScalingTest.kt | 31 +
.../view/compose/UpdateWindowStateTest.kt | 123 +
.../view/compose/WindowShortcutsTest.kt | 45 +
.../map/ComposeStationMapDrawingTest.kt | 266 +
.../map/ComposeStationMapLayoutTest.kt | 80 +
.../map/PathAnalysisDetailsLayoutTest.kt | 65 +
.../map/PathAnalysisDetailsWidthTest.kt | 83 +
.../compose/map/PathProfileChartLayoutTest.kt | 46 +
.../compose/map/StationMapBodyLayoutTest.kt | 119 +
.../view/compose/map/TileBitmapCacheTest.kt | 82 +
.../view/compose/map/TileKeyTest.kt | 64 +
.../view/compose/tabs/AirscoutTabStateTest.kt | 132 +
.../view/compose/tabs/BeaconTabStateTest.kt | 138 +
.../compose/tabs/GuiOptionsTabStateTest.kt | 49 +
.../view/compose/tabs/LogSynchTabStateTest.kt | 156 +
.../tabs/MessageHandlingTabStateTest.kt | 65 +
.../compose/tabs/NotificationTabStateTest.kt | 272 +
.../view/compose/tabs/ProfilesTabStateTest.kt | 168 +
.../compose/tabs/ShortcutsTabStateTest.kt | 117 +
.../view/compose/tabs/StationTabStateTest.kt | 98 +
.../view/compose/tabs/TrxSynchTabStateTest.kt | 113 +
.../tabs/WorkedDatabaseTabStateTest.kt | 80 +
.../src/test/resources/settings-fields.txt | 72 +
.../resources/settings-foreign-writes.txt | 5 +
build.gradle.kts | 3 +
.../map/MapCallsignRawSnapshotBuilder.java | 10 +-
.../view/map/MapMarkerCluster.java | 53 +
.../view/map/MapMarkerClusterTooltip.java | 51 +
.../kst4contest/view/map/MapViewport.java | 230 +
.../view/map/StationMapClusterer.java | 160 +
.../view/map/StationMapStatusText.java | 99 +
.../kst4contest/view/map/TileFetcher.java | 311 ++
.../view/map/WebMercatorProjection.java | 33 +
.../MapCallsignRawSnapshotBuilderTest.java | 39 +
.../view/map/MapMarkerClusterTooltipTest.java | 80 +
.../kst4contest/view/map/MapViewportTest.java | 206 +
.../view/map/StationMapClustererTest.java | 206 +
.../view/map/StationMapStatusTextTest.java | 150 +
.../kst4contest/view/map/TileFetcherTest.java | 279 +
...09-27-etappe3a-kotlin-compose-toolchain.md | 893 ++++
...-09-27-etappe3b-datatable-einstellungen.md | 852 +++
...9-27-etappe4-update-und-monitor-fenster.md | 248 +
.../plans/2026-09-28-etappe5-hauptfenster.md | 91 +
...tappe5c-task13-hauptfenster-zusammenbau.md | 153 +
...2026-10-01-etappe6-karte-compose-parity.md | 200 +
.../2026-09-25-compose-migration-design.md | 218 +-
gradle.properties | 8 +-
gradle/libs.versions.toml | 5 +
jstack_dump.txt | 198 +
packaging/aur/kst4contest-git/PKGBUILD | 16 +-
packaging/aur/kst4contest/PKGBUILD | 32 +-
packaging/macos/build-signed-dmg.sh | 50 +-
scratch/AAA01.png | Bin 0 -> 621654 bytes
scratch/AAA02.png | Bin 0 -> 853603 bytes
scratch/AAA03.png | Bin 0 -> 819084 bytes
scratch/AAA04.png | Bin 0 -> 631922 bytes
scratch/FilterBarRewrite.kt | 207 +
scratch/UpdateFilterBar.py | 182 +
173 files changed, 26500 insertions(+), 4305 deletions(-)
create mode 100644 app-desktop/src/main/java/kst4contest/view/ComposeChatInputActions.java
create mode 100644 app-desktop/src/main/java/kst4contest/view/ComposeMenuActions.java
delete mode 100644 app-desktop/src/main/java/kst4contest/view/OperatorProfilePickerDialog.java
delete mode 100644 app-desktop/src/main/java/kst4contest/view/OperatorProfileSettingsPane.java
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/BuildInfo.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/AirplaneCell.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/ChatInputRow.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/ChatInputState.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeWindowHost.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/ConnectionBar.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/DataTable.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableState.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableView.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/Density.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/DirectedMessageColumns.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/DirectedMessageRowStyle.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/FormControls.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/JavaFxStylesheet.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuBar.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuState.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindow.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowFrame.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowHost.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowState.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/MessageFormats.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/MessageTabs.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/MonitorColumns.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/MonitorWindow.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/ObservedValue.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/OperatorProfilePickerWindow.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/PublicMessageColumns.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationMessageColumns.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationPanel.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationState.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsTabs.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsWindow.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/SnippetContextMenu.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/SplitterPane.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/SplitterState.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/StationColumns.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterBar.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterState.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/StationRowStyle.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/StatusBar.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/StatusIndicatorState.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/Theme.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/TimelineState.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/TimelineView.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/TopPriorityState.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/UpdateWindow.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/UpdateWindowState.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/WindowShortcuts.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/map/ComposeStationMap.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/map/MapOverlayControls.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/map/PathAnalysisDetails.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/map/PathProfileChart.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/map/StationMapWindow.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/map/TileBitmapCache.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/AirscoutTab.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/AirscoutTabState.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/BeaconTab.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/BeaconTabState.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ExternalInterfaceFields.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/GuiOptionsTab.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/GuiOptionsTabState.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/LogSynchTab.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/LogSynchTabState.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/MessageHandlingTab.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/MessageHandlingTabState.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/NotificationTab.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/NotificationTabState.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ProfilesTab.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ProfilesTabState.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ShortcutsTab.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ShortcutsTabState.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTab.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTabState.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/TrxSynchTab.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/TrxSynchTabState.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/WorkedDatabaseTab.kt
create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/WorkedDatabaseTabState.kt
create mode 100644 app-desktop/src/test/java/kst4contest/view/ComposeMainWindowOptionTest.java
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/BuildInfoTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/AirplaneCellTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/ChatInputStateTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/DataTableStateTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/DirectedMessageColumnsTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/DirectedMessageRowStyleTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/EditableListStateTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/JavaFxStylesheetTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/MainMenuStateTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/MainWindowFrameTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/MainWindowStateTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/MonitorColumnsTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/ObservedValueTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/OperatorProfilePickerStateTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/PublicMessageColumnsTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/SelectedStationStateTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/SettingsFieldCoverageTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/SettingsForeignWriteCoverageTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/SplitterStateTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/StationColumnsTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/StationFilterStateTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/StatusIndicatorStateTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/TimelineStateTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/TopPriorityStateTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/TypographyScalingTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/UpdateWindowStateTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/WindowShortcutsTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/map/ComposeStationMapDrawingTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/map/ComposeStationMapLayoutTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/map/PathAnalysisDetailsLayoutTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/map/PathAnalysisDetailsWidthTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/map/PathProfileChartLayoutTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/map/StationMapBodyLayoutTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/map/TileBitmapCacheTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/map/TileKeyTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/AirscoutTabStateTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/BeaconTabStateTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/GuiOptionsTabStateTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/LogSynchTabStateTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/MessageHandlingTabStateTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/NotificationTabStateTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ProfilesTabStateTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ShortcutsTabStateTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StationTabStateTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/TrxSynchTabStateTest.kt
create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/WorkedDatabaseTabStateTest.kt
create mode 100644 app-desktop/src/test/resources/settings-fields.txt
create mode 100644 app-desktop/src/test/resources/settings-foreign-writes.txt
create mode 100644 core/src/main/java/kst4contest/view/map/MapMarkerCluster.java
create mode 100644 core/src/main/java/kst4contest/view/map/MapMarkerClusterTooltip.java
create mode 100644 core/src/main/java/kst4contest/view/map/MapViewport.java
create mode 100644 core/src/main/java/kst4contest/view/map/StationMapClusterer.java
create mode 100644 core/src/main/java/kst4contest/view/map/StationMapStatusText.java
create mode 100644 core/src/main/java/kst4contest/view/map/TileFetcher.java
create mode 100644 core/src/main/java/kst4contest/view/map/WebMercatorProjection.java
create mode 100644 core/src/test/java/kst4contest/view/map/MapMarkerClusterTooltipTest.java
create mode 100644 core/src/test/java/kst4contest/view/map/MapViewportTest.java
create mode 100644 core/src/test/java/kst4contest/view/map/StationMapClustererTest.java
create mode 100644 core/src/test/java/kst4contest/view/map/StationMapStatusTextTest.java
create mode 100644 core/src/test/java/kst4contest/view/map/TileFetcherTest.java
create mode 100644 docs/superpowers/plans/2026-09-27-etappe3a-kotlin-compose-toolchain.md
create mode 100644 docs/superpowers/plans/2026-09-27-etappe3b-datatable-einstellungen.md
create mode 100644 docs/superpowers/plans/2026-09-27-etappe4-update-und-monitor-fenster.md
create mode 100644 docs/superpowers/plans/2026-09-28-etappe5-hauptfenster.md
create mode 100644 docs/superpowers/plans/2026-09-28-etappe5c-task13-hauptfenster-zusammenbau.md
create mode 100644 docs/superpowers/plans/2026-10-01-etappe6-karte-compose-parity.md
create mode 100644 jstack_dump.txt
create mode 100644 scratch/AAA01.png
create mode 100644 scratch/AAA02.png
create mode 100644 scratch/AAA03.png
create mode 100644 scratch/AAA04.png
create mode 100644 scratch/FilterBarRewrite.kt
create mode 100644 scratch/UpdateFilterBar.py
diff --git a/.github/workflows/nightly-artifacts.yml b/.github/workflows/nightly-artifacts.yml
index 1bac8606..a1b9ea74 100644
--- a/.github/workflows/nightly-artifacts.yml
+++ b/.github/workflows/nightly-artifacts.yml
@@ -52,39 +52,23 @@ jobs:
shell: pwsh
run: choco install wixtoolset --no-progress -y
- - name: Build JAR and copy runtime dependencies
- shell: pwsh
- run: |
- .\gradlew.bat -S :app-desktop:collectRuntime
- $jar = Get-ChildItem -Path target -Filter 'praktiKST-*.jar' | Sort-Object LastWriteTime -Descending | Select-Object -First 1
- if (-not $jar) {
- throw "No project JAR produced"
- }
- Copy-Item $jar.FullName app-desktop/build/dist-libs/app.jar
-
- - name: Build app-image with jpackage
+ - name: Build app-image
shell: pwsh
run: |
+ .\gradlew.bat -S :app-desktop:createDistributable
New-Item -ItemType Directory -Force -Path dist | Out-Null
- $addModules = (Select-String -Path gradle.properties -Pattern '^jpackageAddModules=').Line.Split('=')[1]
- if ($LASTEXITCODE -ne 0) { throw "Failed to resolve --add-modules from module-info.java" }
- jpackage `
- --type app-image `
- --name praktiKST `
- --icon packaging/icons/kst4contest.ico `
- --input app-desktop/build/dist-libs `
- --main-jar app.jar `
- --main-class kst4contest.view.Main `
- --add-modules $addModules `
- --dest dist
+ # The next step reads dist/KST4Contest, so the Compose output goes
+ # there. The module list now lives in app-desktop/build.gradle.kts.
+ if (Test-Path dist/KST4Contest) { Remove-Item -Recurse -Force dist/KST4Contest }
+ Copy-Item -Recurse app-desktop/build/compose/binaries/main/app/KST4Contest dist/KST4Contest
- name: Create Windows ZIP
shell: pwsh
run: |
- if (-not (Test-Path dist/praktiKST)) {
- throw "No Windows app-image produced by jpackage"
+ if (-not (Test-Path dist/KST4Contest)) {
+ throw "No Windows app-image produced by createDistributable"
}
- Compress-Archive -Path dist/praktiKST -DestinationPath "dist/$env:ASSET_BASENAME-windows-x64.zip" -Force
+ Compress-Archive -Path dist/KST4Contest -DestinationPath "dist/$env:ASSET_BASENAME-windows-x64.zip" -Force
- name: Upload Windows artifact
uses: actions/upload-artifact@v4.3.4
@@ -116,23 +100,14 @@ jobs:
- name: Ensure gradlew is executable
run: chmod +x gradlew
- - name: Build JAR and copy runtime dependencies
- run: |
- ./gradlew -S :app-desktop:collectRuntime
-
- - name: Build app-image with jpackage
+ - name: Build app-image
run: |
+ ./gradlew -S :app-desktop:createDistributable
mkdir -p dist
- ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
- jpackage \
- --type app-image \
- --name KST4Contest \
- --icon packaging/icons/kst4contest.png \
- --input app-desktop/build/dist-libs \
- --main-jar app.jar \
- --main-class kst4contest.view.Main \
- --add-modules "$ADD_MODULES" \
- --dest dist
+ # The following steps read dist/KST4Contest, so the Compose output goes
+ # there. The module list now lives in app-desktop/build.gradle.kts.
+ rm -rf dist/KST4Contest
+ cp -a app-desktop/build/compose/binaries/main/app/KST4Contest dist/
- name: Create AppDir metadata
run: |
@@ -203,27 +178,13 @@ jobs:
- name: Ensure gradlew is executable
run: chmod +x gradlew
- - name: Build JAR and copy runtime dependencies
- run: |
- ./gradlew -S :app-desktop:collectRuntime
-
- name: Build Debian package
run: |
+ ./gradlew -S :app-desktop:packageDeb
mkdir -p dist
- ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
- jpackage \
- --type deb \
- --name KST4Contest \
- --icon packaging/icons/kst4contest.png \
- --input app-desktop/build/dist-libs \
- --main-jar app.jar \
- --main-class kst4contest.view.Main \
- --add-modules "$ADD_MODULES" \
- --linux-package-deps "libgstreamer1.0-0,libgstreamer-plugins-base1.0-0,gstreamer1.0-plugins-good" \
- --dest dist
- DEB="$(ls dist/*.deb | head -n 1)"
+ DEB="$(ls app-desktop/build/compose/binaries/main/deb/*.deb | head -n 1)"
if [ -z "$DEB" ]; then
- echo "No DEB produced by jpackage" && exit 1
+ echo "No DEB produced by packageDeb" && exit 1
fi
mv "$DEB" "dist/${ASSET_BASENAME}-debian-amd64.deb"
@@ -262,27 +223,13 @@ jobs:
- name: Ensure gradlew is executable
run: chmod +x gradlew
- - name: Build JAR and copy runtime dependencies
- run: |
- ./gradlew -S :app-desktop:collectRuntime
-
- name: Build Fedora package
run: |
+ ./gradlew -S :app-desktop:packageRpm
mkdir -p dist
- ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
- jpackage \
- --type rpm \
- --name KST4Contest \
- --icon packaging/icons/kst4contest.png \
- --input app-desktop/build/dist-libs \
- --main-jar app.jar \
- --main-class kst4contest.view.Main \
- --add-modules "$ADD_MODULES" \
- --linux-package-deps "gstreamer1,gstreamer1-plugins-base,gstreamer1-plugins-good" \
- --dest dist
- RPM="$(ls dist/*.rpm | head -n 1)"
+ RPM="$(ls app-desktop/build/compose/binaries/main/rpm/*.rpm | head -n 1)"
if [ -z "$RPM" ]; then
- echo "No RPM produced by jpackage" && exit 1
+ echo "No RPM produced by packageRpm" && exit 1
fi
mv "$RPM" "dist/${ASSET_BASENAME}-fedora-x86_64.rpm"
@@ -323,23 +270,14 @@ jobs:
- name: Ensure gradlew is executable
run: chmod +x gradlew
- - name: Build JAR and copy runtime dependencies
- run: |
- ./gradlew -S :app-desktop:collectRuntime
-
- - name: Build app-image with jpackage
+ - name: Build app-image
run: |
+ ./gradlew -S :app-desktop:createDistributable
mkdir -p dist
- ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
- jpackage \
- --type app-image \
- --name KST4Contest \
- --icon packaging/icons/kst4contest.png \
- --input app-desktop/build/dist-libs \
- --main-jar app.jar \
- --main-class kst4contest.view.Main \
- --add-modules "$ADD_MODULES" \
- --dest dist
+ # The following steps read dist/KST4Contest, so the Compose output goes
+ # there. The module list now lives in app-desktop/build.gradle.kts.
+ rm -rf dist/KST4Contest
+ cp -a app-desktop/build/compose/binaries/main/app/KST4Contest dist/
- name: Build Arch Linux package artifact
run: |
@@ -445,20 +383,13 @@ jobs:
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
flatpak --user install -y flathub org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08
- - name: Build app-image with jpackage
+ - name: Build app-image
run: |
- ./gradlew -S :app-desktop:collectRuntime
+ ./gradlew -S :app-desktop:createDistributable
mkdir -p target/flatpak-src
- ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
- jpackage \
- --type app-image \
- --name KST4Contest \
- --icon packaging/icons/kst4contest.png \
- --input app-desktop/build/dist-libs \
- --main-jar app.jar \
- --main-class kst4contest.view.Main \
- --add-modules "$ADD_MODULES" \
- --dest target/flatpak-src
+ # The manifest's source is flatpak-src/KST4Contest.
+ rm -rf target/flatpak-src/KST4Contest
+ cp -a app-desktop/build/compose/binaries/main/app/KST4Contest target/flatpak-src/
- name: Create Flatpak manifest
run: |
diff --git a/.github/workflows/tagged-release.yml b/.github/workflows/tagged-release.yml
index f999238b..e70de310 100644
--- a/.github/workflows/tagged-release.yml
+++ b/.github/workflows/tagged-release.yml
@@ -34,39 +34,23 @@ jobs:
shell: pwsh
run: choco install wixtoolset --no-progress -y
- - name: Build JAR and copy runtime dependencies
- shell: pwsh
- run: |
- .\gradlew.bat -S :app-desktop:collectRuntime
- $jar = Get-ChildItem -Path target -Filter 'praktiKST-*.jar' | Sort-Object LastWriteTime -Descending | Select-Object -First 1
- if (-not $jar) {
- throw "No project JAR produced"
- }
- Copy-Item $jar.FullName app-desktop/build/dist-libs/app.jar
-
- - name: Build app-image with jpackage
+ - name: Build app-image
shell: pwsh
run: |
+ .\gradlew.bat -S :app-desktop:createDistributable
New-Item -ItemType Directory -Force -Path dist | Out-Null
- $addModules = (Select-String -Path gradle.properties -Pattern '^jpackageAddModules=').Line.Split('=')[1]
- if ($LASTEXITCODE -ne 0) { throw "Failed to resolve --add-modules from module-info.java" }
- jpackage `
- --type app-image `
- --name praktiKST `
- --icon packaging/icons/kst4contest.ico `
- --input app-desktop/build/dist-libs `
- --main-jar app.jar `
- --main-class kst4contest.view.Main `
- --add-modules $addModules `
- --dest dist
+ # The next step reads dist/KST4Contest, so the Compose output goes
+ # there. The module list now lives in app-desktop/build.gradle.kts.
+ if (Test-Path dist/KST4Contest) { Remove-Item -Recurse -Force dist/KST4Contest }
+ Copy-Item -Recurse app-desktop/build/compose/binaries/main/app/KST4Contest dist/KST4Contest
- name: Create Windows ZIP
shell: pwsh
run: |
- if (-not (Test-Path dist/praktiKST)) {
- throw "No Windows app-image produced by jpackage"
+ if (-not (Test-Path dist/KST4Contest)) {
+ throw "No Windows app-image produced by createDistributable"
}
- Compress-Archive -Path dist/praktiKST -DestinationPath dist/praktiKST-${{ github.ref_name }}-windows-x64.zip -Force
+ Compress-Archive -Path dist/KST4Contest -DestinationPath dist/praktiKST-${{ github.ref_name }}-windows-x64.zip -Force
- name: Upload Windows artifact
uses: actions/upload-artifact@v4.3.4
@@ -91,23 +75,14 @@ jobs:
- name: Ensure gradlew is executable
run: chmod +x gradlew
- - name: Build JAR and copy runtime dependencies
- run: |
- ./gradlew -S :app-desktop:collectRuntime
-
- - name: Build app-image with jpackage
+ - name: Build app-image
run: |
+ ./gradlew -S :app-desktop:createDistributable
mkdir -p dist
- ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
- jpackage \
- --type app-image \
- --name KST4Contest \
- --icon packaging/icons/kst4contest.png \
- --input app-desktop/build/dist-libs \
- --main-jar app.jar \
- --main-class kst4contest.view.Main \
- --add-modules "$ADD_MODULES" \
- --dest dist
+ # The following steps read dist/KST4Contest, so the Compose output goes
+ # there. The module list now lives in app-desktop/build.gradle.kts.
+ rm -rf dist/KST4Contest
+ cp -a app-desktop/build/compose/binaries/main/app/KST4Contest dist/
- name: Create AppDir metadata
run: |
@@ -171,27 +146,13 @@ jobs:
- name: Ensure gradlew is executable
run: chmod +x gradlew
- - name: Build JAR and copy runtime dependencies
- run: |
- ./gradlew -S :app-desktop:collectRuntime
-
- name: Build Debian package
run: |
+ ./gradlew -S :app-desktop:packageDeb
mkdir -p dist
- ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
- jpackage \
- --type deb \
- --name KST4Contest \
- --icon packaging/icons/kst4contest.png \
- --input app-desktop/build/dist-libs \
- --main-jar app.jar \
- --main-class kst4contest.view.Main \
- --add-modules "$ADD_MODULES" \
- --linux-package-deps "libgstreamer1.0-0,libgstreamer-plugins-base1.0-0,gstreamer1.0-plugins-good" \
- --dest dist
- DEB="$(ls dist/*.deb | head -n 1)"
+ DEB="$(ls app-desktop/build/compose/binaries/main/deb/*.deb | head -n 1)"
if [ -z "$DEB" ]; then
- echo "No DEB produced by jpackage" && exit 1
+ echo "No DEB produced by packageDeb" && exit 1
fi
mv "$DEB" "dist/KST4Contest-${{ github.ref_name }}-debian-amd64.deb"
@@ -223,27 +184,13 @@ jobs:
- name: Ensure gradlew is executable
run: chmod +x gradlew
- - name: Build JAR and copy runtime dependencies
- run: |
- ./gradlew -S :app-desktop:collectRuntime
-
- name: Build Fedora package
run: |
+ ./gradlew -S :app-desktop:packageRpm
mkdir -p dist
- ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
- jpackage \
- --type rpm \
- --name KST4Contest \
- --icon packaging/icons/kst4contest.png \
- --input app-desktop/build/dist-libs \
- --main-jar app.jar \
- --main-class kst4contest.view.Main \
- --add-modules "$ADD_MODULES" \
- --linux-package-deps "gstreamer1,gstreamer1-plugins-base,gstreamer1-plugins-good" \
- --dest dist
- RPM="$(ls dist/*.rpm | head -n 1)"
+ RPM="$(ls app-desktop/build/compose/binaries/main/rpm/*.rpm | head -n 1)"
if [ -z "$RPM" ]; then
- echo "No RPM produced by jpackage" && exit 1
+ echo "No RPM produced by packageRpm" && exit 1
fi
mv "$RPM" "dist/KST4Contest-${{ github.ref_name }}-fedora-x86_64.rpm"
@@ -275,23 +222,14 @@ jobs:
- name: Ensure gradlew is executable
run: chmod +x gradlew
- - name: Build JAR and copy runtime dependencies
- run: |
- ./gradlew -S :app-desktop:collectRuntime
-
- - name: Build app-image with jpackage
+ - name: Build app-image
run: |
+ ./gradlew -S :app-desktop:createDistributable
mkdir -p dist
- ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
- jpackage \
- --type app-image \
- --name KST4Contest \
- --icon packaging/icons/kst4contest.png \
- --input app-desktop/build/dist-libs \
- --main-jar app.jar \
- --main-class kst4contest.view.Main \
- --add-modules "$ADD_MODULES" \
- --dest dist
+ # The following steps read dist/KST4Contest, so the Compose output goes
+ # there. The module list now lives in app-desktop/build.gradle.kts.
+ rm -rf dist/KST4Contest
+ cp -a app-desktop/build/compose/binaries/main/app/KST4Contest dist/
- name: Build Arch Linux package artifact
run: |
@@ -388,20 +326,13 @@ jobs:
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
flatpak --user install -y flathub org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08
- - name: Build app-image with jpackage
+ - name: Build app-image
run: |
- ./gradlew -S :app-desktop:collectRuntime
+ ./gradlew -S :app-desktop:createDistributable
mkdir -p target/flatpak-src
- ADD_MODULES="$(grep -m1 '^jpackageAddModules=' gradle.properties | cut -d= -f2)"
- jpackage \
- --type app-image \
- --name KST4Contest \
- --icon packaging/icons/kst4contest.png \
- --input app-desktop/build/dist-libs \
- --main-jar app.jar \
- --main-class kst4contest.view.Main \
- --add-modules "$ADD_MODULES" \
- --dest target/flatpak-src
+ # The manifest's source is flatpak-src/KST4Contest.
+ rm -rf target/flatpak-src/KST4Contest
+ cp -a app-desktop/build/compose/binaries/main/app/KST4Contest target/flatpak-src/
- name: Create Flatpak manifest
run: |
diff --git a/.gitignore b/.gitignore
index fec6ff69..6e89ba3a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -59,3 +59,9 @@ website/_site/
build/
core/build/
app-desktop/build/
+
+# Agent scratch area - screenshots, notes, throwaway scripts
+scratch/
+
+# Kotlin incremental build data
+.kotlin/
diff --git a/CLAUDE.md b/CLAUDE.md
index 46ffc7e0..30db188b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -29,5 +29,5 @@ sind über beide Module hinweg dieselben.
Spezifikationen liegen unter `docs/superpowers/specs/`, Umsetzungspläne unter
`docs/superpowers/plans/`. Der aktuelle Umbau ist die Ablösung von JavaFX durch
-Compose Multiplatform in sechs Etappen; Etappe 1 (Gradle, zwei Module, JPMS raus)
+Compose Multiplatform in neun Etappen; Etappe 1 (Gradle, zwei Module, JPMS raus)
ist abgeschlossen.
diff --git a/app-desktop/build.gradle.kts b/app-desktop/build.gradle.kts
index 4e898281..3bd3597a 100644
--- a/app-desktop/build.gradle.kts
+++ b/app-desktop/build.gradle.kts
@@ -1,7 +1,9 @@
plugins {
java
- application
alias(libs.plugins.javafx)
+ alias(libs.plugins.kotlinJvm)
+ alias(libs.plugins.composeCompiler)
+ alias(libs.plugins.composeMultiplatform)
}
javafx {
@@ -9,13 +11,20 @@ javafx {
modules = listOf("javafx.controls", "javafx.fxml", "javafx.web", "javafx.media")
}
+kotlin {
+ jvmToolchain(21)
+}
+
dependencies {
+ implementation("org.openjfx:javafx-swing:${libs.versions.javafx.get()}")
// Mirrors the compile-scope entries in pom.xml: 11 test classes live under
// src/main/java and need JUnit and Mockito on the main compile classpath.
implementation(libs.junit.jupiter.api)
implementation(libs.mockito.core.compile)
implementation(libs.jetbrains.annotations)
implementation(project(":core"))
+ implementation(compose.desktop.currentOs)
+ implementation(compose.material3)
implementation(libs.sqlite.jdbc)
implementation(libs.jlayer)
testImplementation(libs.junit.jupiter)
@@ -25,46 +34,77 @@ dependencies {
testRuntimeOnly(libs.junit.platform.launcher)
}
-application {
- mainClass.set("kst4contest.view.Kst4ContestApplication")
-}
+compose.desktop {
+ application {
+ // Main and not Kst4ContestApplication: an Application subclass cannot be
+ // started from the classpath, which is where JavaFX lives since Etappe 1.
+ mainClass = "kst4contest.view.Main"
-// Collects the application jar and its runtime dependencies into the layout the
-// packaging scripts expect: a flat directory with the main jar named app.jar.
-val collectRuntime by tasks.registering(Sync::class) {
- dependsOn(tasks.jar)
- from(tasks.jar) { rename { "app.jar" } }
- from(configurations.runtimeClasspath)
- into(layout.buildDirectory.dir("dist-libs"))
-}
+ nativeDistributions {
+ /*
+ * Only the formats the building machine can actually produce. Compose rejects a
+ * foreign one while CONFIGURING the project, not while packaging it, so a list
+ * naming all five made every Gradle task fail on macOS and Windows — including
+ * `run` and `test`, which package nothing. The build was Linux-only without
+ * anyone having decided that.
+ */
+ val host = org.gradle.internal.os.OperatingSystem.current()
+ targetFormats(
+ *when {
+ host.isMacOsX -> arrayOf(
+ org.jetbrains.compose.desktop.application.dsl.TargetFormat.Dmg,
+ )
+ host.isWindows -> arrayOf(
+ org.jetbrains.compose.desktop.application.dsl.TargetFormat.Msi,
+ )
+ else -> arrayOf(
+ org.jetbrains.compose.desktop.application.dsl.TargetFormat.AppImage,
+ org.jetbrains.compose.desktop.application.dsl.TargetFormat.Deb,
+ org.jetbrains.compose.desktop.application.dsl.TargetFormat.Rpm,
+ )
+ }
+ )
+ /*
+ * jlink must come from JDK 21, not from whatever the Gradle JVM is.
+ * jdk.jsobject was removed after 21, and this machine has 26 on PATH;
+ * Etappe 1 hit the same wall with jpackage.
+ */
+ javaHome = javaToolchains.launcherFor {
+ languageVersion.set(JavaLanguageVersion.of(21))
+ }.get().metadata.installationPath.asFile.absolutePath
-val packageImage by tasks.registering(Exec::class) {
- dependsOn(collectRuntime)
- // jpackage must come from the project toolchain, not from PATH: jdk.jsobject
- // was removed after JDK 21, and jlink then cannot resolve the module list.
- val launcher = javaToolchains.launcherFor {
- languageVersion.set(JavaLanguageVersion.of(21))
- }.get()
- val jpackageTool = launcher.metadata.installationPath.file("bin/jpackage").asFile.absolutePath
- val input = layout.buildDirectory.dir("dist-libs").get().asFile
- val output = layout.buildDirectory.dir("jpackage").get().asFile
- val os = org.gradle.internal.os.OperatingSystem.current()
- val icon = when {
- os.isWindows -> "packaging/icons/kst4contest.ico"
- os.isMacOsX -> "packaging/icons/kst4contest.icns"
- else -> "packaging/icons/kst4contest.png"
+ /*
+ * KST4Contest and not praktiKST: this one name drives the app-image
+ * directory, the launcher, the .app bundle, the bundled icon file
+ * and -- lowercased -- the deb/rpm package name. Every packaging
+ * consumer already uses KST4Contest there (AppRun, the Arch and
+ * Flatpak wrappers, the AUR PKGBUILDs, build-signed-dmg.sh), and the
+ * shipped deb/rpm must keep the package identity "kst4contest" so an
+ * installed copy still upgrades.
+ */
+ packageName = "KST4Contest"
+ packageVersion = providers.gradleProperty("composePackageVersion").get()
+
+ /*
+ * Kept from the former module-info.java, minus the javafx.* modules.
+ * jdk.jsobject carries netscape.javascript for the map bridge, and
+ * jdk.unsupported carries sun.misc.Unsafe, without which the JavaFX
+ * Marlin renderer fails to start. Neither is inferred: JavaFX comes
+ * from the classpath, so nothing declares them.
+ */
+ modules(
+ "java.desktop", "java.net.http", "java.sql",
+ "jdk.crypto.ec", "jdk.jsobject", "jdk.net",
+ "jdk.xml.dom", "jdk.unsupported",
+ )
+
+ linux { iconFile.set(rootProject.file("packaging/icons/kst4contest.png")) }
+ windows { iconFile.set(rootProject.file("packaging/icons/kst4contest.ico")) }
+ macOS {
+ iconFile.set(rootProject.file("packaging/icons/kst4contest.icns"))
+ // Must stay de.x08.KST4Contest: build-signed-dmg.sh signs against it.
+ bundleID = "de.x08.KST4Contest"
+ }
+ }
}
- doFirst { output.deleteRecursively() }
- commandLine(
- jpackageTool,
- "--icon", rootProject.file(icon).absolutePath,
- "--type", "app-image",
- "--name", "praktiKST",
- "--input", input.absolutePath,
- "--dest", output.absolutePath,
- "--main-jar", "app.jar",
- "--add-modules", providers.gradleProperty("jpackageAddModules").get(),
- "--main-class", "kst4contest.view.Main",
- "--java-options", "-Dfile.encoding=UTF-8"
- )
}
diff --git a/app-desktop/src/main/java/kst4contest/view/CommandLineOptions.java b/app-desktop/src/main/java/kst4contest/view/CommandLineOptions.java
index b0c571b2..0843817f 100644
--- a/app-desktop/src/main/java/kst4contest/view/CommandLineOptions.java
+++ b/app-desktop/src/main/java/kst4contest/view/CommandLineOptions.java
@@ -22,11 +22,29 @@ public class CommandLineOptions {
*/
public static final String PROFILE_SYSTEM_PROPERTY = "kst4contest.profile";
+ /**
+ * Opens the Compose main window beside the JavaFX one.
+ *
+ * Off unless asked for. The two are meant to run side by side while the operator
+ * compares them against a live session; until that comparison is done, an ordinary start
+ * must behave exactly as it did.
+ */
+ public static final String COMPOSE_MAIN_WINDOW_ARGUMENT = "--compose-main-window";
+
private static volatile CommandLineOptions rememberedOptions = new CommandLineOptions(null);
private final String requestedProfileName;
+ private final boolean composeMainWindowRequested;
+
public CommandLineOptions(final String requestedProfileName) {
+ this(requestedProfileName, false);
+ }
+
+ public CommandLineOptions(
+ final String requestedProfileName,
+ final boolean composeMainWindowRequested) {
+ this.composeMainWindowRequested = composeMainWindowRequested;
this.requestedProfileName = requestedProfileName;
}
@@ -42,6 +60,7 @@ public class CommandLineOptions {
public static CommandLineOptions parse(final List rawArguments) {
String requestedProfileName = null;
+ boolean composeMainWindow = false;
if (rawArguments != null) {
for (int argumentIndex = 0; argumentIndex < rawArguments.size(); argumentIndex++) {
@@ -51,7 +70,9 @@ public class CommandLineOptions {
continue;
}
- if (currentArgument.startsWith(PROFILE_ARGUMENT + "=")) {
+ if (COMPOSE_MAIN_WINDOW_ARGUMENT.equals(currentArgument)) {
+ composeMainWindow = true;
+ } else if (currentArgument.startsWith(PROFILE_ARGUMENT + "=")) {
requestedProfileName = currentArgument.substring(PROFILE_ARGUMENT.length() + 1);
} else if (PROFILE_ARGUMENT.equals(currentArgument)
&& argumentIndex + 1 < rawArguments.size()) {
@@ -70,7 +91,8 @@ public class CommandLineOptions {
}
return new CommandLineOptions(
- requestedProfileName == null ? null : requestedProfileName.trim());
+ requestedProfileName == null ? null : requestedProfileName.trim(),
+ composeMainWindow);
}
/**
@@ -96,6 +118,15 @@ public class CommandLineOptions {
*
* @return the requested profile name, or null when none was given
*/
+ /**
+ * Whether the Compose main window should be opened beside the JavaFX one.
+ *
+ * @return {@code true} only when --compose-main-window was passed
+ */
+ public boolean isComposeMainWindowRequested() {
+ return composeMainWindowRequested;
+ }
+
public String getRequestedProfileName() {
return requestedProfileName;
}
diff --git a/app-desktop/src/main/java/kst4contest/view/ComposeChatInputActions.java b/app-desktop/src/main/java/kst4contest/view/ComposeChatInputActions.java
new file mode 100644
index 00000000..683b9b3b
--- /dev/null
+++ b/app-desktop/src/main/java/kst4contest/view/ComposeChatInputActions.java
@@ -0,0 +1,99 @@
+package kst4contest.view;
+
+import kst4contest.model.ChatCategory;
+import kst4contest.model.ChatMember;
+import kst4contest.model.ChatMessage;
+import kst4contest.view.compose.ChatInputActions;
+
+/**
+ * What the Compose send line needs from the runtime.
+ *
+ * Every method forwards to the same code the JavaFX send button runs, so that while the two
+ * windows sit side by side a message sent from either takes exactly one path. The order the
+ * steps are called in lives in {@code ChatInputState}, where it is tested against the
+ * original; this class only answers questions.
+ */
+public final class ComposeChatInputActions implements ChatInputActions {
+
+ private final Kst4ContestApplication application;
+
+ public ComposeChatInputActions(final Kst4ContestApplication application) {
+ this.application = application;
+ }
+
+ @Override
+ public ChatMember panelSelection() {
+ return application.selectedCallSignInfoStageChatMember;
+ }
+
+ @Override
+ public ChatMember tableSelection() {
+ if (application.tbl_chatMember == null
+ || application.tbl_chatMember.getSelectionModel() == null) {
+ return null;
+ }
+ return application.tbl_chatMember.getSelectionModel().getSelectedItem();
+ }
+
+ @Override
+ public ChatMember scoreSelection() {
+ if (application.chatcontroller == null
+ || application.chatcontroller.getScoreService() == null) {
+ return null;
+ }
+ return application.chatcontroller.getScoreService().getSelectedChatMember();
+ }
+
+ @Override
+ public String resolveVariables(final String template, final ChatMember member) {
+ if (application.messageVariableResolver == null) {
+ return template;
+ }
+ return application.messageVariableResolver.resolveForSelectedStation(template, member);
+ }
+
+ @Override
+ public boolean isAddressedToOwnCallsign(final String text) {
+ return application.isMessageAddressedToOwnCallsignFromCompose(text);
+ }
+
+ @Override
+ public ChatCategory resolveCategory(final String text, final ChatMember member) {
+ return application.resolveOutgoingChatCategoryFromCompose(text, member);
+ }
+
+ @Override
+ public ChatCategory mainCategory() {
+ return application.chatcontroller.getChatCategoryMain();
+ }
+
+ @Override
+ public void recordOutboundCq(final String text) {
+ application.chatcontroller.getStationMetricsService()
+ .tryRecordOutboundCq(text, System.currentTimeMillis());
+ }
+
+ @Override
+ public void requestRecompute(final String reason) {
+ if (application.chatcontroller.getScoreService() != null) {
+ application.chatcontroller.getScoreService().requestRecompute(reason);
+ }
+ }
+
+ @Override
+ public void queue(final ChatMessage message) {
+ application.chatcontroller.getMessageTXBus().add(message);
+ }
+
+ @Override
+ public String ownQrgMain() {
+ String value = application.chatcontroller.getChatPreferences().getMYQRGFirstCat().get();
+ return value == null ? "" : value;
+ }
+
+ @Override
+ public String ownQrgSecond() {
+ String value = application.chatcontroller.getChatPreferences().getMYQRGSecondCat().get();
+ return value == null ? "" : value;
+ }
+}
diff --git a/app-desktop/src/main/java/kst4contest/view/ComposeMenuActions.java b/app-desktop/src/main/java/kst4contest/view/ComposeMenuActions.java
new file mode 100644
index 00000000..1d9ddf73
--- /dev/null
+++ b/app-desktop/src/main/java/kst4contest/view/ComposeMenuActions.java
@@ -0,0 +1,136 @@
+package kst4contest.view;
+
+import javafx.scene.control.MenuItem;
+import kst4contest.view.compose.MainMenuActions;
+
+import java.util.function.Supplier;
+
+/**
+ * The Compose menu bar's actions, delegated to the JavaFX menu items they mirror.
+ *
+ * Firing the very same {@link MenuItem} is deliberate. The two bars run side by side while
+ * the operator compares the windows, and a second implementation of seventeen handlers would
+ * be a second thing to compare — differences between the bars would be indistinguishable from
+ * differences between the windows.
+ *
+ * When the JavaFX window goes, those handlers move into methods of their own and this class
+ * calls those instead. Until then it is a bridge, not a design.
+ */
+public final class ComposeMenuActions implements MainMenuActions {
+
+ private final Kst4ContestApplication application;
+
+ public ComposeMenuActions(final Kst4ContestApplication application) {
+ this.application = application;
+ }
+
+ @Override
+ public void connect() {
+ fire(() -> application.menuItemFileConnect);
+ }
+
+ @Override
+ public void disconnect() {
+ fire(() -> application.menuItemFileDisconnect);
+ }
+
+ @Override
+ public void switchOperatorProfile() {
+ application.runOnUi(application::showOperatorProfileSwitchDialogFromCompose);
+ }
+
+ @Override
+ public void exitApplication() {
+ fire(() -> application.menuItemFileExit);
+ }
+
+ @Override
+ public void setQrgAsNameInChat() {
+ fire(() -> application.menuItemOptionsSetFrequencyAsName);
+ }
+
+ @Override
+ public void toggleAwayState() {
+ fire(() -> application.menuItemOptionsAwayBack);
+ }
+
+ @Override
+ public void toggleSettingsWindow() {
+ fire(() -> application.menuItemOptionsShow);
+ }
+
+ @Override
+ public void toggleMonitorWindow() {
+ fire(() -> application.menuItemWindowCluster);
+ }
+
+ @Override
+ public void toggleStationMap() {
+ fire(() -> application.menuItemWindowStationMap);
+ }
+
+ @Override
+ public void useDarkDesign() {
+ fire(() -> application.menuItemWindowDarkDesign);
+ }
+
+ @Override
+ public void useDefaultDesign() {
+ fire(() -> application.menuItemWindowDefaultDesign);
+ }
+
+ @Override
+ public void openDonationPage() {
+ fire(() -> application.menuItemInfoDonate);
+ }
+
+ @Override
+ public void openHomepage() {
+ fire(() -> application.menuItemInfoHomepage);
+ }
+
+ @Override
+ public void openNewsgroup() {
+ fire(() -> application.menuItemInfoNewsgroup);
+ }
+
+ @Override
+ public void openChangelog() {
+ /*
+ * The JavaFX item and its handler are both commented out, so there is nothing to
+ * fire. The Compose menu no longer offers this either; the method stays because the
+ * interface is shared and an empty body is honest about there being no such feature.
+ */
+ }
+
+ @Override
+ public void openOv3tDonationPage() {
+ fire(() -> application.menuItemInfoDonateOv3t);
+ }
+
+ @Override
+ public void contactAuthor() {
+ fire(() -> application.menuItemInfoContact);
+ }
+
+ @Override
+ public void showAbout() {
+ fire(() -> application.menuItemInfoAbout);
+ }
+
+ /**
+ * Fires a menu item on the JavaFX thread.
+ *
+ * The item is looked up when the action runs rather than when this class is built,
+ * because initMenuBar creates them after the runtime exists. A missing item is ignored:
+ * a menu entry that cannot act is better than a crash mid contest.
+ */
+ private void fire(final Supplier
*/
private void forceChatMemberFilterRefresh() {
+ updateComposeFilters();
applyChatMemberFilterPredicates();
}
+ private void updateComposeFilters() {
+ if (composeMainWindowState == null) return;
+ kst4contest.view.compose.StationFilterState state = composeMainWindowState.getStationFilter();
+
+ String text = state.getSearchText().trim();
+ boolean hasText = !text.isEmpty();
+
+ java.util.function.Predicate unifiedPredicate = member -> {
+ if (hasText) {
+ String cs = member.getCallSign();
+ if (cs == null || !cs.toUpperCase(java.util.Locale.ROOT).contains(text.toUpperCase(java.util.Locale.ROOT))) {
+ return false;
+ }
+ }
+
+ if (state.isOn(kst4contest.view.compose.StationFilter.ONLY_NEW_GRIDS) && !chatcontroller.isNewGridSquare(member)) return false;
+ if (state.isOn(kst4contest.view.compose.StationFilter.TROPO_REACHABLE) && !isReachableViaTropoFilterMatch(member)) return false;
+ if (state.isOn(kst4contest.view.compose.StationFilter.NEW_BANDS) && !isNewBandOpportunity(member)) return false;
+ if (state.isOn(kst4contest.view.compose.StationFilter.AIRSCOUT_NEXT_5_MIN) && !hasAsWindowInNextMinutes(member, 5)) return false;
+
+ if (state.isOn(kst4contest.view.compose.StationFilter.MAX_QRB)) {
+ Double qrb = member.getQrb();
+ if (qrb == null || qrb > state.getMaxQrbKm()) return false;
+ }
+
+ if (state.isOn(kst4contest.view.compose.StationFilter.QTF)) {
+ Double qtf = member.getQTFdirection();
+ if (qtf == null || !kst4contest.locatorUtils.DirectionUtils.isAngleInRange(qtf, state.getQtfDegrees(), chatcontroller.getChatPreferences().getStn_antennaBeamWidthDeg())) return false;
+ }
+
+ if (state.isOn(kst4contest.view.compose.StationFilter.HIDE_INACTIVE)) {
+ long inactiveMinutes = kst4contest.controller.Utils4KST.time_getSecondsBetweenEpochAndNow(member.getActivityTimeLastInEpoch() + "") / 60;
+ if (inactiveMinutes > 20L) return false;
+ }
+
+ if (state.isOn(kst4contest.view.compose.StationFilter.HIDE_WORKED_ANY) && member.isWorked()) return false;
+
+ if (state.isOn(kst4contest.view.compose.StationFilter.HIDE_WORKED_50) && (member.isWorked50() || !member.isQrv50())) return false;
+ if (state.isOn(kst4contest.view.compose.StationFilter.HIDE_WORKED_70) && (member.isWorked70() || !member.isQrv70())) return false;
+ if (state.isOn(kst4contest.view.compose.StationFilter.HIDE_WORKED_144) && (member.isWorked144() || !member.isQrv144())) return false;
+ if (state.isOn(kst4contest.view.compose.StationFilter.HIDE_WORKED_432) && (member.isWorked432() || !member.isQrv432())) return false;
+ if (state.isOn(kst4contest.view.compose.StationFilter.HIDE_WORKED_23) && (member.isWorked1240() || !member.isQrv1240())) return false;
+ if (state.isOn(kst4contest.view.compose.StationFilter.HIDE_WORKED_13) && (member.isWorked2300() || !member.isQrv2300())) return false;
+ if (state.isOn(kst4contest.view.compose.StationFilter.HIDE_WORKED_9) && (member.isWorked3400() || !member.isQrv3400())) return false;
+ if (state.isOn(kst4contest.view.compose.StationFilter.HIDE_WORKED_6) && (member.isWorked5600() || !member.isQrv5600())) return false;
+ if (state.isOn(kst4contest.view.compose.StationFilter.HIDE_WORKED_3) && (member.isWorked10G() || !member.isQrv10G())) return false;
+
+ return true;
+ };
+
+ composeMainWindowState.getStations().setRowFilter(unifiedPredicate::test);
+
+ chatcontroller.clearChatMemberListFilterPredicates();
+ chatcontroller.addChatMemberListFilterPredicate(unifiedPredicate);
+ }
+
@Override
public void onUserListUpdated() {
scheduleUserListRefresh(null);
@@ -13027,6 +10037,9 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
if (tbl_chatMember != null) {
tbl_chatMember.refresh();
}
+ if (composeMainWindowState != null) {
+ composeMainWindowState.getStations().forceRedraw();
+ }
refreshStationMapIfVisible();
@@ -13378,6 +10391,869 @@ public class Kst4ContestApplication extends Application implements StatusUpdateL
}
}
+ /*************************************************************************************
+ * Settings window (Compose)
+ *
+ * The window, its eleven tabs and the rules of every field live in Kotlin under
+ * kst4contest.view.compose. What stays here is the work only this class can do:
+ * rebuilding JavaFX controls, and starting or dropping the ON4KST session.
+ ************************************************************************************/
+
+ /**
+ * There is no configurable base font size; the JavaFX settings window used the
+ * platform default. This is the same value the operator profile picker uses, so both
+ * Compose windows read like the JavaFX ones next to them.
+ */
+ private static final float SETTINGS_WINDOW_FONT_SIZE_SP = 12f;
+
+ private final SettingsNotices settingsNotices = new SettingsNotices();
+
+ /**
+ * The monitor window's roster subscriptions. Held so shutdownRuntime can release
+ * them: a discarded runtime that still feeds a closed window keeps itself alive.
+ */
+ private java.util.function.Consumer> monitorClusterListener;
+
+ private java.util.function.Consumer> monitorChatListener;
+
+ /** Releases the monitor window's roster subscriptions. */
+ private void releaseMonitorListeners() {
+
+ if (monitorClusterListener != null) {
+ chatcontroller.getLst_clusterMemberList().removeListener(monitorClusterListener);
+ monitorClusterListener = null;
+ }
+
+ if (monitorChatListener != null) {
+ chatcontroller.getLst_globalChatMessageList().removeListener(monitorChatListener);
+ monitorChatListener = null;
+ }
+ }
+
+ /*****************************************************
+ * The Compose main window, opened beside the JavaFX one
+ ****************************************************/
+
+ /** Held so the feeds can be released; a discarded runtime that still feeds a window lives on. */
+ private MainWindowState composeMainWindowState;
+ private java.util.function.Consumer> composeMemberListener;
+ private java.util.function.Consumer> composeChatListener;
+ private java.util.function.Consumer> composeClusterListener;
+
+ /**
+ * Opens the Compose main window beside the JavaFX one.
+ *
+ * Only on --compose-main-window, and never a second time. The two run side by side so
+ * the operator can compare them against a live session, which is the only way the
+ * differences of the last three Etappen came to light.
+ */
+ private void openComposeMainWindowIfRequested() {
+
+ // Etappe 5c: Compose Hauptfenster wird immer gestartet
+ // if (!CommandLineOptions.remembered().isComposeMainWindowRequested()) {
+ // return;
+ // }
+
+ // if (MainWindowHost.isOpen()) {
+ // return;
+ // }
+
+ ColumnWidthStore widths = new ColumnWidthStore() {
+ @Override
+ public Double width(String tableId, String columnId) {
+ java.util.OptionalDouble stored =
+ chatcontroller.getChatPreferences().getTableColumnWidth(tableId, columnId);
+ return stored.isPresent() ? stored.getAsDouble() : null;
+ }
+
+ @Override
+ public void setWidth(String tableId, String columnId, double width) {
+ chatcontroller.getChatPreferences().setTableColumnWidth(tableId, columnId, width);
+ }
+
+ @Override
+ public void requestSave() {
+ requestLayoutSave();
+ }
+ };
+
+ /*
+ * Own table ids, deliberately. The Compose window's columns are the operator's to
+ * arrange separately while both windows are on screen; sharing the ids would let a
+ * drag in one silently rearrange the other.
+ */
+ DataTableState stations = new DataTableState<>(
+ StationColumns.INSTANCE.all(
+ member -> {
+ Band band = resolveReachabilityBandForUi(member);
+ chatcontroller.getReachabilityService()
+ .ensureTropoMarginCalculated(member, band);
+ return member.formatTropoSsbMarginForBand(band);
+ },
+ member -> parseTableDouble(formatPriorityScore(member)),
+ (member, band, worked) -> formatBandCellStatus(member, band, worked),
+ BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences())),
+ RowKeys.INSTANCE.byValue(ChatMember::getCallSign),
+ "compose-stations",
+ widths);
+
+ DataTableState directed = new DataTableState<>(
+ DirectedMessageColumns.INSTANCE.all(
+ () -> chatcontroller.getChatPreferences().getStn_loginCallSign(),
+ chatcontroller::formatChatMessageTextForDisplay),
+ RowKeys.INSTANCE.byReference(),
+ "compose-directed-messages",
+ widths);
+
+ DataTableState publicMessages = new DataTableState<>(
+ PublicMessageColumns.INSTANCE.all(),
+ RowKeys.INSTANCE.byReference(),
+ "compose-public-messages",
+ widths);
+
+ DataTableState cluster = new DataTableState<>(
+ MonitorColumns.INSTANCE.dxCluster(),
+ RowKeys.INSTANCE.byReference(),
+ "compose-dx-cluster",
+ widths);
+
+ DataTableState qsoOfTheOther = new DataTableState<>(
+ MonitorColumns.INSTANCE.qsoOfTheOther(),
+ RowKeys.INSTANCE.byReference(),
+ "compose-qso-other",
+ widths);
+
+ DataTableState selectedStationMessages = new DataTableState<>(
+ SelectedStationMessageColumns.INSTANCE.all(),
+ RowKeys.INSTANCE.byReference(),
+ "compose-selected-station-messages",
+ widths);
+
+ MainMenuState menu = new MainMenuState();
+ MainWindowSurroundings surroundings = new MainWindowSurroundings(menu);
+
+ SelectedStationState selectedStation = new SelectedStationState(
+ member -> {
+ chatcontroller.propagateNotQrvStateToActiveMembers(member);
+ return kotlin.Unit.INSTANCE;
+ },
+ this::createSkedFromCompose,
+ member -> kotlin.Unit.INSTANCE,
+ member -> kotlin.Unit.INSTANCE,
+ filter -> {
+ if (filter == null) {
+ return kotlin.Unit.INSTANCE;
+ }
+ Predicate predicate;
+ 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;
+ },
+ member -> {
+ if (member == null || member.getCallSignRaw() == null) {
+ return "";
+ }
+ return formatDetectedRxBandsForCallsignRaw(member.getCallSignRaw(), 30L * 60L * 1000L);
+ },
+ member -> {
+ if (member == null) return null;
+ java.util.EnumSet enabledBands = kst4contest.logic.BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences());
+ return resolveDefaultSkedBand(member, enabledBands);
+ });
+
+ if (chatcontroller.getChatPreferences().isGuiOptions_defaultFilterPmToMe()) {
+ selectedStation.setMessageFilter(SelectedMessageFilter.PM_TO_ME);
+ } else if (chatcontroller.getChatPreferences().isGuiOptions_defaultFilterPmToOther()) {
+ selectedStation.setMessageFilter(SelectedMessageFilter.PM_TO_OTHER);
+ } else {
+ selectedStation.setMessageFilter(SelectedMessageFilter.NOTHING);
+ }
+
+ TopPriorityState topPriority = new TopPriorityState(
+ this::composeTopStations,
+ member -> selectStationInCompose(selectedStation, stations, member));
+
+ ChatInputState chatInput = new ChatInputState(new ComposeChatInputActions(this));
+
+ composeMainWindowState = new MainWindowState(
+ BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences()),
+ chatcontroller.getChatPreferences(),
+ stations,
+ new StationFilterState(),
+ directed,
+ publicMessages,
+ cluster,
+ qsoOfTheOther,
+ selectedStationMessages,
+ selectedStation,
+ topPriority,
+ new TimelineState(),
+ chatInput,
+ menu,
+ surroundings,
+ new BlinkingNotice(),
+ new BlinkingNotice(),
+ new ThreadStatusButtons(),
+ new SplitterState(
+ 2,
+ chatcontroller.getChatPreferences().getGUImainWindowLeftSplitPane_dividerposition(),
+ SplitterState.DEFAULT_MIN_PANE_PX,
+ positions -> {
+ chatcontroller.getChatPreferences()
+ .setGUImainWindowLeftSplitPane_dividerposition(positions);
+ requestLayoutSave();
+ return kotlin.Unit.INSTANCE;
+ }),
+ new SplitterState(
+ MainWindowState.MESSAGE_PANE_COUNT,
+ chatcontroller.getChatPreferences().getGUImessageSectionSplitpane_dividerposition(),
+ SplitterState.DEFAULT_MIN_PANE_PX,
+ positions -> {
+ chatcontroller.getChatPreferences()
+ .setGUImessageSectionSplitpane_dividerposition(positions);
+ requestLayoutSave();
+ return kotlin.Unit.INSTANCE;
+ }),
+ new SplitterState(
+ MainWindowState.RIGHT_PANE_COUNT,
+ chatcontroller.getChatPreferences().getGUImainWindowRightSplitPane_dividerposition(),
+ SplitterState.DEFAULT_MIN_PANE_PX,
+ positions -> {
+ chatcontroller.getChatPreferences()
+ .setGUImainWindowRightSplitPane_dividerposition(positions);
+ requestLayoutSave();
+ return kotlin.Unit.INSTANCE;
+ }));
+
+ menu.setConnectLabel(buildComposeConnectLabel());
+
+ /*
+ * Subscribed first and seeded afterwards, so a message arriving in between is not
+ * missed — the order FxRosterBinding uses for the same reason.
+ */
+ composeMemberListener = rows -> uiDispatcher.runOnUi(() -> stations.replaceRows(rows));
+ chatcontroller.getLst_chatMemberList().addListener(composeMemberListener);
+
+ composeChatListener = rows -> uiDispatcher.runOnUi(() -> {
+ directed.replaceRows(chatcontroller.toMeMessages());
+ publicMessages.replaceRows(rows);
+ qsoOfTheOther.replaceRows(chatcontroller.toOtherMessages());
+ });
+ chatcontroller.getLst_globalChatMessageList().addListener(composeChatListener);
+
+ composeClusterListener = rows -> uiDispatcher.runOnUi(() -> cluster.replaceRows(rows));
+ chatcontroller.getLst_clusterMemberList().addListener(composeClusterListener);
+
+ if (chatcontroller.getScoreService() != null) {
+ chatcontroller.getScoreService().topCandidates().addListener(candidates -> uiDispatcher.runOnUi(() -> topPriority.refresh()));
+ }
+ uiDispatcher.runOnUi(() -> topPriority.refresh());
+
+ stations.replaceRows(chatcontroller.getLst_chatMemberList().snapshot());
+ directed.replaceRows(chatcontroller.toMeMessages());
+ publicMessages.replaceRows(chatcontroller.getLst_globalChatMessageList().snapshot());
+ cluster.replaceRows(chatcontroller.getLst_clusterMemberList().snapshot());
+ qsoOfTheOther.replaceRows(chatcontroller.toOtherMessages());
+ if (selectedCallSignInfoMessageBinding != null) {
+ selectedStationMessages.replaceRows(new java.util.ArrayList<>(selectedCallSignInfoMessageBinding.list()));
+ }
+
+ double[] storedSize = chatcontroller.getChatPreferences().getGUIscn_ChatwindowMainSceneSizeHW();
+ javafx.geometry.Rectangle2D screen = Screen.getPrimary().getVisualBounds();
+ MainWindowSize size = MainWindowFrame.INSTANCE.startupSize(
+ storedSize, screen.getHeight(), screen.getWidth());
+
+ MainWindowHost.show(
+ composeMainWindowState,
+ new ComposeMenuActions(this),
+ MainWindowFrame.INSTANCE.title(
+ "KST4Contest (Compose)", buildOperatorProfileTitleSuffixForCompose()),
+ chatcontroller.getChatPreferences().isGUI_darkModeActive(),
+ SETTINGS_WINDOW_FONT_SIZE_SP,
+ (float) size.getWidthDp(),
+ (float) size.getHeightDp(),
+ (width, height) -> {
+ /*
+ * Not written to the profile while both windows live: the JavaFX window
+ * owns that setting, and two windows writing one size would leave the
+ * operator's arrangement to whichever was resized last.
+ */
+ return kotlin.Unit.INSTANCE;
+ },
+ () -> composeSkedBands(selectedStation.getSelected()),
+ address -> runOnUi(() -> getHostServices().showDocument(address)),
+ () -> {
+ runOnUi(() -> showSelectedCallsignOnMap());
+ return kotlin.Unit.INSTANCE;
+ },
+ () -> {
+ if (selectedCallSignInfoStageChatMember != null) {
+ chatcontroller.airScout_SendAsShowPathPacket(selectedCallSignInfoStageChatMember);
+ }
+ return kotlin.Unit.INSTANCE;
+ },
+ member -> selectStationInCompose(selectedStation, stations, member),
+ message -> onDirectedMessageClickedInCompose(selectedStation, stations, composeMainWindowState.getChatInput(), message),
+ this::isOwnChatMessage,
+ this::chatMessageAgeSeconds,
+ () -> {
+ runOnUi(() -> {
+ updateComposeFilters();
+ });
+ return kotlin.Unit.INSTANCE;
+ },
+ () -> {
+ runOnUi(() -> showTopPriorityCandidatesWindow(tbl_chatMember, txt_chatMessageUserInput));
+ return kotlin.Unit.INSTANCE;
+ },
+ candidate -> {
+ if (candidate == null) return kotlin.Unit.INSTANCE;
+ ChatMember resolved = resolveChatMemberForCallRawAndCategory(
+ candidate.getCallSignRaw(), candidate.getPreferredChatCategory());
+ if (resolved != null) {
+ selectStationInCompose(selectedStation, stations, resolved);
+ composeMainWindowState.getChatInput().prepareCq(resolved.getCallSign(), false);
+
+ // Sync with JavaFX state just in case
+ prepareCqTextForSelectedChatMember(resolved, false);
+ focusChatMemberAndPrepareCq(resolved, false);
+ }
+ return kotlin.Unit.INSTANCE;
+ });
+
+ updateComposeSurroundings();
+ }
+
+ /**
+ * Arranges a sked from the Compose panel, exactly as the JavaFX sked button does.
+ *
+ * @return whether it was arranged; a station without a bearing still gets one, at 0°, as
+ * the original did
+ */
+ private boolean createSkedFromCompose(
+ final ChatMember selectedMember,
+ final int minutes,
+ final Band band,
+ final String mode) {
+
+ if (selectedMember == null || band == null) {
+ return false;
+ }
+
+ long skedTime = System.currentTimeMillis() + minutes * 60_000L;
+ double azimuth =
+ selectedMember.getQTFdirection() != null ? selectedMember.getQTFdirection() : 0.0;
+
+ ContestSked sked = new ContestSked(
+ selectedMember.getCallSignRaw(),
+ selectedMember.getCallSign(),
+ selectedMember.getChatCategory(),
+ azimuth,
+ skedTime,
+ band);
+
+ chatcontroller.addSked(sked);
+ chatcontroller.getScoreService().requestRecompute("sked-created");
+
+ // Arm PM reminders, exactly as the JavaFX Create sked handler does
+ if (composeMainWindowState != null) {
+ SelectedStationState ssState = composeMainWindowState.getSelectedStation();
+ if (ssState.getRemindPm()) {
+ List offsets = parseMinuteOffsets(ssState.getReminderOffsets());
+ chatcontroller.getSkedReminderService().armReminders(
+ sked.getTargetChatCallsign(),
+ sked.getTargetChatCategory(),
+ skedTime,
+ offsets
+ );
+ }
+ }
+
+ return true;
+ }
+
+ /** The Connect item's label, built exactly as initMenuBar builds it. */
+ private String buildComposeConnectLabel() {
+ ChatCategory mainCat = chatcontroller.getChatPreferences().getLoginChatCategoryMain();
+ if (mainCat == null) {
+ return "Connect";
+ }
+
+ String label = "Connect to " + mainCat.getChatCategoryName(mainCat.getCategoryNumber());
+ if (chatcontroller.getChatPreferences().isLoginToSecondChatEnabled()) {
+ ChatCategory secondCat = chatcontroller.getChatPreferences().getLoginChatCategorySecond();
+ if (secondCat != null) {
+ label += " & " + secondCat.getChatCategoryName(secondCat.getCategoryNumber());
+ }
+ }
+ return label;
+ }
+
+ /** The profile name for the title, or null for the root profile. */
+ private String buildOperatorProfileTitleSuffixForCompose() {
+ OperatorProfileSelection activeProfile = ActiveOperatorProfile.get();
+ if (activeProfile == null || activeProfile.getProfile().isRootProfile()) {
+ return null;
+ }
+ return activeProfile.getProfile().getDisplayName();
+ }
+
+ /** The bands a sked can be arranged on for this station: enabled here, available there. */
+ private java.util.List composeSkedBands(ChatMember selectedMember) {
+ if (selectedMember == null) {
+ return java.util.List.of();
+ }
+
+ java.util.List variants =
+ chatcontroller.findActiveChatMembersByRawCall(selectedMember.getCallSignRaw());
+ if (variants.isEmpty()) {
+ variants = java.util.List.of(selectedMember);
+ }
+
+ BandOpportunityResolver.Resolution resolution =
+ BandOpportunityResolver.resolve(variants, System.currentTimeMillis());
+ EnumSet enabledBands =
+ BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences());
+
+ EnumSet available = EnumSet.copyOf(resolution.getAvailableBands());
+ available.retainAll(enabledBands);
+ return new ArrayList<>(available);
+ }
+
+ /** The score service's ranking, as the Compose priority bar wants it. */
+ private java.util.List> composeTopStations() {
+ java.util.List> out = new ArrayList<>();
+
+ if (chatcontroller.getScoreService() == null) {
+ return out;
+ }
+
+ for (ScoreService.TopCandidate candidate
+ : chatcontroller.getScoreService().topCandidates().snapshot()) {
+
+ /*
+ * A candidate names a callsign, not a station. The first active variant is the one
+ * the JavaFX buttons resolved to as well; a callsign with no variant left the chat
+ * between the ranking and this draw.
+ */
+ java.util.List variants =
+ chatcontroller.findActiveChatMembersByRawCall(candidate.getCallSignRaw());
+ if (!variants.isEmpty()) {
+ out.add(new kotlin.Pair<>(variants.get(0), candidate.getScore()));
+ }
+ }
+ return out;
+ }
+
+ /**
+ * Selects a station in the Compose window: the panel, the table and the score service, the
+ * three places the JavaFX selection listener kept in step.
+ */
+ private kotlin.Unit selectStationInCompose(
+ SelectedStationState panel,
+ DataTableState table,
+ ChatMember member) {
+
+ panel.select(member);
+ table.select(member);
+
+ selectedCallSignInfoStageChatMember = member;
+ if (selectedCallSignInfoMessageBinding != null) {
+ selectedCallSignInfoMessageBinding.refresh();
+ Platform.runLater(() -> {
+ if (composeMainWindowState != null) {
+ uiDispatcher.runOnUi(() -> composeMainWindowState.getSelectedStationMessages().replaceRows(
+ new java.util.ArrayList<>(selectedCallSignInfoMessageBinding.list())
+ ));
+ }
+ });
+ }
+
+ if (member != null && chatcontroller.getScoreService() != null) {
+ chatcontroller.getScoreService().setSelectedChatMember(member);
+
+ if (composeMainWindowState != null && composeMainWindowState.getChatInput() != null) {
+ composeMainWindowState.getChatInput().prepareCq(member.getCallSign(), false);
+ }
+
+ // Also sync JavaFX side
+ runOnUi(() -> {
+ programmaticChatMemberSelectionChange = true;
+ try {
+ focusChatMemberAndPrepareCq(member, false);
+ } finally {
+ programmaticChatMemberSelectionChange = false;
+ }
+ });
+ }
+ return kotlin.Unit.INSTANCE;
+ }
+
+ private kotlin.Unit onDirectedMessageClickedInCompose(
+ SelectedStationState panel,
+ DataTableState stationsTable,
+ ChatInputState chatInput,
+ ChatMessage message) {
+
+ if (message == null || message.getSender() == null) {
+ return kotlin.Unit.INSTANCE;
+ }
+
+ if (message.getSender().getCallSign().equals(chatcontroller.getChatPreferences().getStn_loginCallSign())) {
+ String receiverCallsign = message.getMessageText().substring(2, message.getMessageText().indexOf(")"));
+ chatInput.prepareCq(receiverCallsign, false);
+
+ // Sync with JavaFX state just in case
+ prepareCqTextForCallsign(receiverCallsign, message.getChatCategory(), false);
+ } else {
+ selectStationInCompose(panel, stationsTable, message.getSender());
+
+ chatInput.prepareCq(message.getSender().getCallSign(), false);
+
+ // Sync with JavaFX state just in case
+ prepareCqTextForSelectedChatMember(message.getSender(), false);
+ focusChatMemberAndPrepareCq(message.getSender(), false);
+ }
+
+ return kotlin.Unit.INSTANCE;
+ }
+
+ /** Whether a message came from this station; decides its row colour. */
+ private boolean isOwnChatMessage(ChatMessage message) {
+ if (message == null || message.getSender() == null) {
+ return false;
+ }
+
+ String ownCall = chatcontroller.getChatPreferences().getStn_loginCallSign();
+ return ownCall != null && ownCall.equals(message.getSender().getCallSign());
+ }
+
+ /** How long ago a message arrived, for the fading highlight. */
+ private long chatMessageAgeSeconds(ChatMessage message) {
+ if (message == null) {
+ return Long.MAX_VALUE;
+ }
+
+ try {
+ long generated = Long.parseLong(message.getMessageGeneratedTime());
+ return (System.currentTimeMillis() / 1000L) - generated;
+ } catch (RuntimeException notATimestamp) {
+ return Long.MAX_VALUE;
+ }
+ }
+
+ /** Pushes the link state and the window flags into the Compose window. */
+ private void updateComposeSurroundings() {
+ if (composeMainWindowState == null) {
+ return;
+ }
+
+ MainWindowSurroundings surroundings = composeMainWindowState.getSurroundings();
+ surroundings.setConnectionState(chatcontroller.getOn4KstConnectionState());
+ surroundings.setSettingsWindowOpen(SettingsWindow.isOpen());
+ surroundings.setMonitorWindowOpen(MonitorWindow.isOpen());
+ composeMainWindowState.getMenu().setAwayFromChat(
+ chatcontroller.getChatPreferences().isStn_loginAFKState());
+ }
+
+ /** Releases the Compose window's feeds; a discarded runtime that still feeds one lives on. */
+ private void releaseComposeMainWindow() {
+ if (composeMemberListener != null) {
+ chatcontroller.getLst_chatMemberList().removeListener(composeMemberListener);
+ composeMemberListener = null;
+ }
+ if (composeChatListener != null) {
+ chatcontroller.getLst_globalChatMessageList().removeListener(composeChatListener);
+ composeChatListener = null;
+ }
+ if (composeClusterListener != null) {
+ chatcontroller.getLst_clusterMemberList().removeListener(composeClusterListener);
+ composeClusterListener = null;
+ }
+ if (composeMainWindowState != null) {
+ composeMainWindowState.release();
+ composeMainWindowState = null;
+ }
+ }
+
+ /**
+ * Opens the cluster and QSO monitor.
+ *
+ * Both tables follow their roster: the listener replaces the rows, and
+ * DataTableState keeps the selection on the row the operator picked rather than on
+ * the position it happened to occupy. The two subscriptions are held in fields and
+ * released by releaseMonitorListeners, because a discarded runtime that still feeds
+ * a closed window keeps itself alive.
+ */
+ private void openMonitorWindow() {
+
+ /*
+ * Guarded before anything is built. Without this a second click registers two
+ * more roster listeners and then hands them to a window that refuses to open,
+ * so every inbound message would re-run toOtherMessages once more over up to
+ * thirty thousand stored messages — work whose result is thrown away, on the
+ * thread that draws.
+ */
+ if (MonitorWindow.isOpen()) {
+ return;
+ }
+
+ ColumnWidthStore widths = new ColumnWidthStore() {
+ @Override
+ public Double width(String tableId, String columnId) {
+ java.util.OptionalDouble stored =
+ chatcontroller.getChatPreferences().getTableColumnWidth(tableId, columnId);
+ return stored.isPresent() ? stored.getAsDouble() : null;
+ }
+
+ @Override
+ public void setWidth(String tableId, String columnId, double width) {
+ chatcontroller.getChatPreferences().setTableColumnWidth(tableId, columnId, width);
+ }
+
+ @Override
+ public void requestSave() {
+ requestLayoutSave();
+ }
+ };
+
+ DataTableState clusterTable = new DataTableState<>(
+ MonitorColumns.INSTANCE.dxCluster(),
+ RowKeys.INSTANCE.byReference(),
+ "dx-cluster-monitor",
+ widths);
+
+ DataTableState qsoTable = new DataTableState<>(
+ MonitorColumns.INSTANCE.qsoOfTheOther(),
+ RowKeys.INSTANCE.byReference(),
+ "qso-other-monitor",
+ widths);
+
+ /*
+ * Subscribed first and seeded afterwards, so a message arriving in between is
+ * not missed — the order FxRosterBinding uses for the same reason.
+ */
+ monitorClusterListener = rows -> uiDispatcher.runOnUi(() -> clusterTable.replaceRows(rows));
+ chatcontroller.getLst_clusterMemberList().addListener(monitorClusterListener);
+
+ /*
+ * The QSO table shows a filtered view, so the whole filter is re-run rather
+ * than the delivered list being used: toOtherMessages is what decides which
+ * messages belong to other operators.
+ */
+ monitorChatListener =
+ rows -> uiDispatcher.runOnUi(() -> qsoTable.replaceRows(chatcontroller.toOtherMessages()));
+ chatcontroller.getLst_globalChatMessageList().addListener(monitorChatListener);
+
+ clusterTable.replaceRows(chatcontroller.getLst_clusterMemberList().snapshot());
+ qsoTable.replaceRows(chatcontroller.toOtherMessages());
+
+ MonitorWindow.show(
+ clusterTable,
+ qsoTable,
+ chatcontroller.getChatPreferences().isGUI_darkModeActive(),
+ SETTINGS_WINDOW_FONT_SIZE_SP,
+ (float) chatcontroller.getChatPreferences().getGUIclusterAndQSOMonStage_SceneSizeHW()[0],
+ (float) chatcontroller.getChatPreferences().getGUIclusterAndQSOMonStage_SceneSizeHW()[1],
+ (width, height) -> {
+ chatcontroller.getChatPreferences().getGUIclusterAndQSOMonStage_SceneSizeHW()[0] = width;
+ chatcontroller.getChatPreferences().getGUIclusterAndQSOMonStage_SceneSizeHW()[1] = height;
+ requestLayoutSave();
+ return kotlin.Unit.INSTANCE;
+ });
+ }
+
+ /**
+ * Opens the update window when the server carries a newer release.
+ *
+ * The decision itself lives in UpdateWindowState and is covered by tests; what
+ * stays here is reaching the system browser, which is the host application's
+ * business.
+ */
+ private void openUpdateWindowIfAvailable() {
+
+ try {
+ UpdateWindowState updateState = new UpdateWindowState(
+ chatcontroller.getUpdateInformation(),
+ ApplicationConstants.APPLICATION_CURRENT_VERSION,
+ ApplicationConstants.APPLICATION_CURRENTVERSIONNUMBER);
+
+ if (!updateState.getUpdateAvailable()) {
+ return;
+ }
+
+ UpdateWindow.show(
+ updateState,
+ chatcontroller.getChatPreferences().isGUI_darkModeActive(),
+ SETTINGS_WINDOW_FONT_SIZE_SP,
+ (float) chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[0],
+ (float) chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[1],
+ address -> getHostServices().showDocument(address),
+ (width, height) -> {
+ chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[0] = width;
+ chatcontroller.getChatPreferences().getGUIstage_updateStage_SceneSizeHW()[1] = height;
+ requestLayoutSave();
+ return kotlin.Unit.INSTANCE;
+ });
+ } catch (Exception updateProblem) {
+ // The client must start even when the update service is unreachable.
+ System.out.println("[KST4ContestApp, ERROR]: Problem on Updateservice! "
+ + updateProblem.getMessage());
+ updateProblem.printStackTrace();
+ }
+ }
+
+ /**
+ * Opens the settings window, or does nothing when it is already open.
+ *
+ * This window carries the Connect button and is therefore the way into the chat,
+ * which is why it is opened once at startup as the JavaFX window was.
+ */
+ private void openSettingsWindow() {
+
+ SettingsWindow.show(
+ SettingsTabsKt.buildSettingsTabs(chatcontroller, this, settingsNotices),
+ settingsNotices,
+ chatcontroller.getChatPreferences().isGUI_darkModeActive(),
+ SETTINGS_WINDOW_FONT_SIZE_SP,
+ (float) chatcontroller.getChatPreferences().getGUIsettingsStageSceneSizeHW()[0],
+ (float) chatcontroller.getChatPreferences().getGUIsettingsStageSceneSizeHW()[1],
+ (width, height) -> {
+ chatcontroller.getChatPreferences().getGUIsettingsStageSceneSizeHW()[0] = width;
+ chatcontroller.getChatPreferences().getGUIsettingsStageSceneSizeHW()[1] = height;
+ requestLayoutSave();
+ return kotlin.Unit.INSTANCE;
+ },
+ SettingsTabsKt.settingsButtons(chatcontroller, this, settingsNotices));
+ }
+
+ @Override
+ public void refreshShortcutButtonsFromSettings() {
+ uiDispatcher.runOnUi(this::refreshShortcutButtons);
+ }
+
+ @Override
+ public void refreshTextSnippetContextMenusFromSettings() {
+ uiDispatcher.runOnUi(this::refreshTextSnippetContextMenus);
+ }
+
+ @Override
+ public String detectWintestBroadcastAddress() {
+
+ try {
+ return detectPreferredWintestBroadcastAddress();
+ } catch (Exception detectionProblem) {
+ System.out.println("[Main.java, Warning]: Could not auto-detect broadcast: "
+ + detectionProblem.getMessage());
+ return null;
+ }
+ }
+
+ @Override
+ public void applyOwnQrgFollower(boolean enabled) {
+
+ uiDispatcher.runOnUi(() -> {
+ if (enabled) {
+ attachOwnQrgFollower();
+ } else {
+ detachOwnQrgFollower();
+ }
+ });
+ }
+
+ /**
+ * The worked-stations rows are a snapshot the tab takes from the roster itself, so
+ * there is nothing for this class to redraw. The JavaFX table needed an explicit
+ * refresh because a TableView does not notice that the objects inside it changed.
+ */
+ @Override
+ public void refreshWorkedStationsView() {
+ // nothing to do: the Compose tab re-reads the roster
+ }
+
+ @Override
+ public void switchToOperatorProfile(OperatorProfile profile) {
+ uiDispatcher.runOnUi(() -> requestOperatorProfileSwitch(profile));
+ }
+
+ /**
+ * Starts the ON4KST session. The credentials are already in the preferences: every
+ * field of the Station tab writes through as it is edited, so there is nothing to
+ * collect here first.
+ */
+ @Override
+ public String connectFromSettings() {
+
+ try {
+ chatcontroller.execute();
+ return null;
+ } catch (InterruptedException interrupted) {
+ Thread.currentThread().interrupt();
+ return "The connection was interrupted: " + interrupted.getMessage();
+ } catch (IOException connectionProblem) {
+ return "The ON4KST server could not be reached: " + connectionProblem.getMessage();
+ }
+ }
+
+ @Override
+ public void disconnectOnlyFromSettings() {
+ chatcontroller.disconnect(ApplicationConstants.DISCSTRING_DISCONNECTONLY);
+ }
+
+ @Override
+ public void disconnectAndCloseChatFromSettings() {
+ uiDispatcher.runOnUi(() -> closeWindowEvent(null));
+ }
+
+ @Override
+ public String savePreferencesFromSettings() {
+
+ if (!chatcontroller.getChatPreferences().writePreferencesToXmlFile()) {
+ return null;
+ }
+
+ if (layoutAutosave != null) {
+ layoutAutosave.cancelPending();
+ }
+
+ return chatcontroller.getChatPreferences().getStoreAndRestorePreferencesFileName();
+ }
+
private String detectPreferredWintestBroadcastAddress() {
String internetRouteBroadcast = detectInternetRouteBroadcastAddress();
if (internetRouteBroadcast != null && !internetRouteBroadcast.isBlank()) {
diff --git a/app-desktop/src/main/java/kst4contest/view/OperatorProfilePickerDialog.java b/app-desktop/src/main/java/kst4contest/view/OperatorProfilePickerDialog.java
deleted file mode 100644
index 42f1d921..00000000
--- a/app-desktop/src/main/java/kst4contest/view/OperatorProfilePickerDialog.java
+++ /dev/null
@@ -1,145 +0,0 @@
-package kst4contest.view;
-
-import kst4contest.model.OperatorProfile;
-
-import javafx.geometry.Insets;
-import javafx.scene.Scene;
-import javafx.scene.control.Button;
-import javafx.scene.control.Label;
-import javafx.scene.control.ListCell;
-import javafx.scene.control.ListView;
-import javafx.scene.input.KeyCode;
-import javafx.scene.input.MouseButton;
-import javafx.scene.layout.HBox;
-import javafx.scene.layout.Priority;
-import javafx.scene.layout.VBox;
-import javafx.stage.Modality;
-import javafx.stage.Stage;
-
-import java.util.List;
-import java.util.Optional;
-
-/**
- * Asks the operator which profile to start with.
- *
- * The dialog is shown only when more than one profile exists. It is intentionally
- * minimal, because it stands between the operator and a contest: the last used profile
- * is preselected, the list has the focus, and Enter or a double click start immediately.
- */
-public final class OperatorProfilePickerDialog {
-
- private OperatorProfilePickerDialog() {
- // Utility class.
- }
-
- /**
- * Shows the picker and waits for the operator's choice.
- *
- * @param selectableProfiles profiles to choose from
- * @param preselectedProfileId identifier of the profile to preselect, may be null
- * @return the chosen profile, or empty when the operator wants to quit
- */
- public static Optional showAndSelect(final List selectableProfiles,
- final String preselectedProfileId) {
-
- Stage dialogStage = new Stage();
- GuiUtils.applyApplicationIcon(dialogStage);
- dialogStage.initModality(Modality.APPLICATION_MODAL);
- dialogStage.setTitle("Select operator profile");
-
- ListView profileListView = new ListView<>();
- profileListView.getItems().addAll(selectableProfiles);
- profileListView.setCellFactory(listView -> new OperatorProfileListCell());
- VBox.setVgrow(profileListView, Priority.ALWAYS);
-
- selectPreselectedProfile(profileListView, selectableProfiles, preselectedProfileId);
-
- OperatorProfile[] chosenProfile = new OperatorProfile[1];
-
- Button startButton = new Button("Start");
- startButton.setDefaultButton(true);
- startButton.setOnAction(event -> {
- chosenProfile[0] = profileListView.getSelectionModel().getSelectedItem();
- dialogStage.close();
- });
-
- Button quitButton = new Button("Quit");
- quitButton.setCancelButton(true);
- quitButton.setOnAction(event -> {
- chosenProfile[0] = null;
- dialogStage.close();
- });
-
- profileListView.setOnMouseClicked(event -> {
- if (event.getButton() == MouseButton.PRIMARY && event.getClickCount() == 2) {
- startButton.fire();
- }
- });
-
- profileListView.setOnKeyPressed(event -> {
- if (event.getCode() == KeyCode.ENTER) {
- startButton.fire();
- }
- });
-
- HBox buttonRow = new HBox(10, startButton, quitButton);
- buttonRow.setPadding(new Insets(10, 0, 0, 0));
-
- VBox dialogContent = new VBox(8,
- new Label("More than one operator profile is configured."),
- profileListView,
- buttonRow);
- dialogContent.setPadding(new Insets(15));
- // No application stylesheet yet: use the same base font size as the main windows.
- dialogContent.setStyle("-fx-font-size: 12px;");
-
- dialogStage.setScene(new Scene(dialogContent, 380, 280));
- profileListView.requestFocus();
- dialogStage.showAndWait();
-
- return Optional.ofNullable(chosenProfile[0]);
- }
-
- private static void selectPreselectedProfile(final ListView profileListView,
- final List selectableProfiles,
- final String preselectedProfileId) {
-
- int profileIndexToSelect = 0;
-
- if (preselectedProfileId != null) {
- for (int profileIndex = 0; profileIndex < selectableProfiles.size(); profileIndex++) {
- if (preselectedProfileId.equalsIgnoreCase(
- selectableProfiles.get(profileIndex).getProfileId())) {
- profileIndexToSelect = profileIndex;
- break;
- }
- }
- }
-
- profileListView.getSelectionModel().select(profileIndexToSelect);
- profileListView.scrollTo(profileIndexToSelect);
- }
-
- /**
- * Renders a profile with its name and the kind of worked data it uses.
- */
- private static final class OperatorProfileListCell extends ListCell {
-
- @Override
- protected void updateItem(final OperatorProfile profile, final boolean empty) {
-
- super.updateItem(profile, empty);
-
- if (empty || profile == null) {
- setText(null);
- return;
- }
-
- String workedDataDescription = profile.isRootProfile() || profile.isSharedWorkedDatabase()
- ? "shared station worked database"
- : "own worked database";
-
- setText(profile.getDisplayName() + "\n" + workedDataDescription);
- }
- }
-}
diff --git a/app-desktop/src/main/java/kst4contest/view/OperatorProfileSettingsPane.java b/app-desktop/src/main/java/kst4contest/view/OperatorProfileSettingsPane.java
deleted file mode 100644
index 444558bf..00000000
--- a/app-desktop/src/main/java/kst4contest/view/OperatorProfileSettingsPane.java
+++ /dev/null
@@ -1,488 +0,0 @@
-package kst4contest.view;
-
-import kst4contest.controller.ActiveOperatorProfile;
-import kst4contest.controller.OperatorProfileManagementService;
-import kst4contest.controller.OperatorProfilePaths;
-import kst4contest.model.OperatorProfile;
-import kst4contest.model.OperatorProfileSelection;
-
-import javafx.beans.property.SimpleStringProperty;
-import javafx.geometry.Insets;
-import javafx.scene.control.Alert;
-import javafx.scene.control.Alert.AlertType;
-import javafx.scene.control.Button;
-import javafx.scene.control.ButtonBar;
-import javafx.scene.control.ButtonType;
-import javafx.scene.control.Dialog;
-import javafx.scene.control.Label;
-import javafx.scene.control.RadioButton;
-import javafx.scene.control.TableColumn;
-import javafx.scene.control.TableView;
-import javafx.scene.control.TextField;
-import javafx.scene.control.TextInputDialog;
-import javafx.scene.control.ToggleGroup;
-import javafx.scene.layout.GridPane;
-import javafx.scene.layout.HBox;
-import javafx.scene.layout.Priority;
-import javafx.scene.layout.VBox;
-import javafx.util.Pair;
-
-import java.time.Instant;
-import java.time.ZoneId;
-import java.time.format.DateTimeFormatter;
-import java.util.List;
-import java.util.Optional;
-import java.util.function.Consumer;
-
-/**
- * Settings tab that manages the operator profiles.
- *
- * Every profile keeps its own settings and window layout. Whether it also keeps its
- * own worked stations is chosen per profile, because a multi operator contest station
- * shares one log while two operators on a private computer usually do not.
- */
-public class OperatorProfileSettingsPane extends VBox {
-
- private static final DateTimeFormatter LAST_USED_FORMATTER =
- DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneId.systemDefault());
-
- private final OperatorProfileManagementService managementService;
- private final Consumer profileActivationRequest;
-
- private final TableView profileTable = new TableView<>();
- private final Label activeProfileLabel = new Label();
- private final Label preferencesPathLabel = new Label();
- private final Label workedDatabasePathLabel = new Label();
-
- public OperatorProfileSettingsPane(final OperatorProfileManagementService managementService,
- final Consumer profileActivationRequest) {
-
- this.managementService = managementService;
- this.profileActivationRequest = profileActivationRequest;
-
- setSpacing(10);
- setPadding(new Insets(15));
-
- getChildren().addAll(
- buildActiveProfileHeader(),
- buildProfileTable(),
- buildButtonRows(),
- buildExplanationLabel());
-
- refreshActiveProfileHeader();
- refreshProfileTable();
- }
-
- private GridPane buildActiveProfileHeader() {
-
- GridPane headerGrid = new GridPane();
- headerGrid.setHgap(10);
- headerGrid.setVgap(4);
-
- headerGrid.add(new Label("Active profile:"), 0, 0);
- headerGrid.add(activeProfileLabel, 1, 0);
- headerGrid.add(new Label("Settings file:"), 0, 1);
- headerGrid.add(preferencesPathLabel, 1, 1);
- headerGrid.add(new Label("Worked stations:"), 0, 2);
- headerGrid.add(workedDatabasePathLabel, 1, 2);
-
- return headerGrid;
- }
-
- private TableView buildProfileTable() {
-
- TableColumn nameColumn = new TableColumn<>("Profile");
- nameColumn.setCellValueFactory(cellData ->
- new SimpleStringProperty(cellData.getValue().getDisplayName()));
- nameColumn.setPrefWidth(200);
-
- TableColumn workedDataColumn = new TableColumn<>("Worked stations");
- workedDataColumn.setCellValueFactory(cellData ->
- new SimpleStringProperty(describeWorkedDataMode(cellData.getValue())));
- workedDataColumn.setPrefWidth(200);
-
- TableColumn lastUsedColumn = new TableColumn<>("Last used");
- lastUsedColumn.setCellValueFactory(cellData ->
- new SimpleStringProperty(describeLastUsed(cellData.getValue())));
- lastUsedColumn.setPrefWidth(140);
-
- profileTable.getColumns().add(nameColumn);
- profileTable.getColumns().add(workedDataColumn);
- profileTable.getColumns().add(lastUsedColumn);
- profileTable.setPlaceholder(new Label("No operator profile configured."));
-
- VBox.setVgrow(profileTable, Priority.ALWAYS);
-
- return profileTable;
- }
-
- private VBox buildButtonRows() {
-
- Button newProfileButton = new Button("New profile...");
- newProfileButton.setOnAction(event -> createProfile());
-
- Button duplicateProfileButton = new Button("Duplicate...");
- duplicateProfileButton.setOnAction(event -> duplicateSelectedProfile());
-
- Button renameProfileButton = new Button("Rename...");
- renameProfileButton.setOnAction(event -> renameSelectedProfile());
-
- Button deleteProfileButton = new Button("Delete...");
- deleteProfileButton.setOnAction(event -> deleteSelectedProfile());
-
- Button changeWorkedDataButton = new Button("Change worked stations...");
- changeWorkedDataButton.setOnAction(event -> changeWorkedDataModeOfSelectedProfile());
-
- Button switchProfileButton = new Button("Switch to selected profile...");
- switchProfileButton.setOnAction(event -> activateSelectedProfile());
-
- HBox managementRow = new HBox(8,
- newProfileButton, duplicateProfileButton, renameProfileButton, deleteProfileButton);
- HBox activationRow = new HBox(8, changeWorkedDataButton, switchProfileButton);
-
- return new VBox(8, managementRow, activationRow);
- }
-
- private Label buildExplanationLabel() {
-
- Label explanation = new Label(
- "Each profile has its own settings and window layout. A profile can either share the "
- + "common station worked stations, which is what a multi operator station wants, "
- + "or keep its own. Duplicating a profile copies everything except callsign and "
- + "password, and never copies worked stations.");
- explanation.setWrapText(true);
-
- return explanation;
- }
-
- private void refreshActiveProfileHeader() {
-
- OperatorProfileSelection activeProfile = ActiveOperatorProfile.get();
-
- if (activeProfile == null) {
- activeProfileLabel.setText("unknown");
- return;
- }
-
- activeProfileLabel.setText(activeProfile.getProfile().getDisplayName());
- preferencesPathLabel.setText(activeProfile.getPreferencesAbsolutePath());
- workedDatabasePathLabel.setText(activeProfile.getWorkedDatabaseAbsolutePath());
- }
-
- private void refreshProfileTable() {
-
- List knownProfiles = managementService.listProfiles();
- OperatorProfile previouslySelected = profileTable.getSelectionModel().getSelectedItem();
-
- profileTable.getItems().setAll(knownProfiles);
-
- if (previouslySelected != null && knownProfiles.contains(previouslySelected)) {
- profileTable.getSelectionModel().select(previouslySelected);
- } else if (!knownProfiles.isEmpty()) {
- profileTable.getSelectionModel().select(0);
- }
- }
-
- private void createProfile() {
-
- Optional> enteredProfile =
- showProfileCreationDialog("New operator profile", "");
-
- if (enteredProfile.isEmpty()) {
- return;
- }
-
- OperatorProfile createdProfile = managementService.createProfile(
- enteredProfile.get().getKey(), enteredProfile.get().getValue());
-
- if (createdProfile == null) {
- showError("The profile could not be created. The profile registry could not be written.");
- return;
- }
-
- refreshProfileTable();
- profileTable.getSelectionModel().select(createdProfile);
-
- showInformation("The profile \"" + createdProfile.getDisplayName() + "\" was created without "
- + "callsign and password. Enter them on the Station tab after switching to it.");
- }
-
- private void duplicateSelectedProfile() {
-
- OperatorProfile selectedProfile = requireSelectedProfile();
-
- if (selectedProfile == null) {
- return;
- }
-
- TextInputDialog nameDialog =
- new TextInputDialog("Copy of " + selectedProfile.getDisplayName());
- nameDialog.setTitle("Duplicate operator profile");
- nameDialog.setHeaderText("Name of the new profile");
- nameDialog.setContentText(
- "Everything is copied except callsign and password. Worked stations are never copied.");
-
- Optional enteredName = nameDialog.showAndWait();
-
- if (enteredName.isEmpty() || enteredName.get().isBlank()) {
- return;
- }
-
- OperatorProfile duplicatedProfile =
- managementService.duplicateProfile(selectedProfile, enteredName.get());
-
- if (duplicatedProfile == null) {
- showError("The profile could not be duplicated.");
- return;
- }
-
- refreshProfileTable();
- profileTable.getSelectionModel().select(duplicatedProfile);
- }
-
- private void renameSelectedProfile() {
-
- OperatorProfile selectedProfile = requireSelectedProfile();
-
- if (selectedProfile == null) {
- return;
- }
-
- TextInputDialog nameDialog = new TextInputDialog(selectedProfile.getDisplayName());
- nameDialog.setTitle("Rename operator profile");
- nameDialog.setHeaderText("New name of the profile");
- nameDialog.setContentText("Files and folders of the profile are not touched.");
-
- Optional enteredName = nameDialog.showAndWait();
-
- if (enteredName.isEmpty() || enteredName.get().isBlank()) {
- return;
- }
-
- managementService.renameProfile(selectedProfile, enteredName.get());
- refreshProfileTable();
- refreshActiveProfileHeader();
- }
-
- private void deleteSelectedProfile() {
-
- OperatorProfile selectedProfile = requireSelectedProfile();
-
- if (selectedProfile == null) {
- return;
- }
-
- if (selectedProfile.isRootProfile()) {
- showError("The default profile uses the files of the installation itself "
- + "and cannot be deleted.");
- return;
- }
-
- if (isActiveProfile(selectedProfile)) {
- showError("The profile currently in use cannot be deleted. Switch to another "
- + "profile first.");
- return;
- }
-
- Alert confirmation = new Alert(AlertType.CONFIRMATION);
- confirmation.setTitle("Delete operator profile");
- confirmation.setHeaderText("Delete the profile \"" + selectedProfile.getDisplayName() + "\"?");
- confirmation.setContentText(
- "The following folder is removed permanently:\n"
- + managementService.getProfileDirectory(selectedProfile)
- + "\n\n"
- + (selectedProfile.isSharedWorkedDatabase()
- ? "The common station worked stations are not touched."
- : "The worked stations of this profile are deleted as well."));
-
- ButtonType deleteButton = new ButtonType("Delete profile", ButtonBar.ButtonData.OK_DONE);
- ButtonType cancelButton = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
- confirmation.getButtonTypes().setAll(deleteButton, cancelButton);
-
- if (confirmation.showAndWait().orElse(cancelButton) != deleteButton) {
- return;
- }
-
- if (!managementService.deleteProfile(selectedProfile)) {
- showError("The profile could not be deleted.");
- }
-
- refreshProfileTable();
- }
-
- private void changeWorkedDataModeOfSelectedProfile() {
-
- OperatorProfile selectedProfile = requireSelectedProfile();
-
- if (selectedProfile == null) {
- return;
- }
-
- if (selectedProfile.isRootProfile()) {
- showError("The default profile always uses the common station worked stations, "
- + "because that database is the one of the installation itself.");
- return;
- }
-
- Optional chosenMode = showWorkedDataModeDialog(selectedProfile);
-
- if (chosenMode.isEmpty() || chosenMode.get() == selectedProfile.isSharedWorkedDatabase()) {
- return;
- }
-
- managementService.setSharedWorkedDatabase(selectedProfile, chosenMode.get());
- refreshProfileTable();
-
- if (isActiveProfile(selectedProfile)) {
- showInformation("The change takes effect after switching to this profile again.");
- }
- }
-
- private void activateSelectedProfile() {
-
- OperatorProfile selectedProfile = requireSelectedProfile();
-
- if (selectedProfile == null) {
- return;
- }
-
- if (isActiveProfile(selectedProfile)) {
- showInformation("This profile is already active.");
- return;
- }
-
- profileActivationRequest.accept(selectedProfile);
- }
-
- private Optional> showProfileCreationDialog(final String title,
- final String initialName) {
-
- Dialog> creationDialog = new Dialog<>();
- creationDialog.setTitle(title);
- creationDialog.setHeaderText("Name and worked stations of the new profile");
-
- ButtonType createButton = new ButtonType("Create profile", ButtonBar.ButtonData.OK_DONE);
- ButtonType cancelButton = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
- creationDialog.getDialogPane().getButtonTypes().setAll(createButton, cancelButton);
-
- TextField nameField = new TextField(initialName);
- nameField.setPromptText("for example DN9APW");
-
- ToggleGroup workedDataGroup = new ToggleGroup();
-
- RadioButton ownDatabaseOption = new RadioButton("Own worked stations for this profile");
- ownDatabaseOption.setToggleGroup(workedDataGroup);
- ownDatabaseOption.setSelected(true);
-
- RadioButton sharedDatabaseOption =
- new RadioButton("Share the common station worked stations (multi operator station)");
- sharedDatabaseOption.setToggleGroup(workedDataGroup);
-
- VBox dialogContent = new VBox(8,
- new Label("Profile name"),
- nameField,
- new Label("Worked stations"),
- ownDatabaseOption,
- sharedDatabaseOption);
- dialogContent.setPadding(new Insets(10));
-
- creationDialog.getDialogPane().setContent(dialogContent);
-
- creationDialog.setResultConverter(pressedButton -> {
- if (pressedButton != createButton || nameField.getText().isBlank()) {
- return null;
- }
-
- return new Pair<>(nameField.getText().trim(), sharedDatabaseOption.isSelected());
- });
-
- return creationDialog.showAndWait();
- }
-
- private Optional showWorkedDataModeDialog(final OperatorProfile profile) {
-
- Dialog modeDialog = new Dialog<>();
- modeDialog.setTitle("Worked stations");
- modeDialog.setHeaderText("Worked stations of \"" + profile.getDisplayName() + "\"");
-
- ButtonType applyButton = new ButtonType("Apply", ButtonBar.ButtonData.OK_DONE);
- ButtonType cancelButton = new ButtonType("Cancel", ButtonBar.ButtonData.CANCEL_CLOSE);
- modeDialog.getDialogPane().getButtonTypes().setAll(applyButton, cancelButton);
-
- ToggleGroup workedDataGroup = new ToggleGroup();
-
- RadioButton ownDatabaseOption = new RadioButton("Own worked stations for this profile");
- ownDatabaseOption.setToggleGroup(workedDataGroup);
-
- RadioButton sharedDatabaseOption =
- new RadioButton("Share the common station worked stations (multi operator station)");
- sharedDatabaseOption.setToggleGroup(workedDataGroup);
-
- sharedDatabaseOption.setSelected(profile.isSharedWorkedDatabase());
- ownDatabaseOption.setSelected(!profile.isSharedWorkedDatabase());
-
- Label pathHint = new Label("Switching does not move any data. Worked stations already "
- + "collected under the other setting stay where they are.");
- pathHint.setWrapText(true);
-
- VBox dialogContent = new VBox(8, ownDatabaseOption, sharedDatabaseOption, pathHint);
- dialogContent.setPadding(new Insets(10));
-
- modeDialog.getDialogPane().setContent(dialogContent);
- modeDialog.setResultConverter(pressedButton ->
- pressedButton == applyButton ? sharedDatabaseOption.isSelected() : null);
-
- return modeDialog.showAndWait();
- }
-
- private OperatorProfile requireSelectedProfile() {
-
- OperatorProfile selectedProfile = profileTable.getSelectionModel().getSelectedItem();
-
- if (selectedProfile == null) {
- showInformation("Select a profile in the table first.");
- }
-
- return selectedProfile;
- }
-
- private static boolean isActiveProfile(final OperatorProfile profile) {
-
- OperatorProfileSelection activeProfile = ActiveOperatorProfile.get();
-
- return activeProfile != null
- && activeProfile.getProfile().getProfileId().equals(profile.getProfileId());
- }
-
- private static String describeWorkedDataMode(final OperatorProfile profile) {
-
- if (profile.isRootProfile() || profile.isSharedWorkedDatabase()) {
- return "common station database";
- }
-
- return "own database";
- }
-
- private static String describeLastUsed(final OperatorProfile profile) {
-
- if (profile.getLastUsedEpochMs() <= 0L) {
- return "";
- }
-
- return LAST_USED_FORMATTER.format(Instant.ofEpochMilli(profile.getLastUsedEpochMs()));
- }
-
- private static void showInformation(final String message) {
- Alert information = new Alert(AlertType.INFORMATION);
- information.setTitle("Operator profiles");
- information.setContentText(message);
- information.showAndWait();
- }
-
- private static void showError(final String message) {
- Alert error = new Alert(AlertType.ERROR);
- error.setTitle("Operator profiles");
- error.setContentText(message);
- error.showAndWait();
- }
-}
diff --git a/app-desktop/src/main/java/kst4contest/view/map/StationMapView.java b/app-desktop/src/main/java/kst4contest/view/map/StationMapView.java
index 26ea772e..6c5c57a3 100644
--- a/app-desktop/src/main/java/kst4contest/view/map/StationMapView.java
+++ b/app-desktop/src/main/java/kst4contest/view/map/StationMapView.java
@@ -1076,9 +1076,7 @@ public final class StationMapView {
}
text.append(" | ")
- .append(String.format(
- Locale.US,
- "%.0f km / %.0f°",
+ .append(StationMapStatusText.distanceAndBearingText(
selectedSnapshot.qrbKm(),
selectedSnapshot.qtfDeg()
));
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/BuildInfo.kt b/app-desktop/src/main/kotlin/kst4contest/view/BuildInfo.kt
new file mode 100644
index 00000000..0c332f56
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/BuildInfo.kt
@@ -0,0 +1,18 @@
+package kst4contest.view
+
+/**
+ * Build identity shown in window titles and in the about text.
+ *
+ * Reads the version the Gradle build stamped into the jar manifest and falls
+ * back to a marker that is obviously not a release, so an unstamped build can
+ * never be mistaken for one.
+ */
+object BuildInfo {
+
+ const val UNKNOWN_VERSION: String = "dev-unstamped"
+
+ val version: String
+ get() = BuildInfo::class.java.`package`?.implementationVersion ?: UNKNOWN_VERSION
+
+ fun windowTitle(base: String): String = "$base $version"
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/AirplaneCell.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/AirplaneCell.kt
new file mode 100644
index 00000000..b27e981d
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/AirplaneCell.kt
@@ -0,0 +1,61 @@
+package kst4contest.view.compose
+
+import androidx.compose.ui.graphics.Color
+import kst4contest.model.AirPlaneReflectionInfo
+
+/**
+ * The aircraft-reflection cell, shared by the station list and the directed messages.
+ *
+ * Both JavaFX tables built this text by hand and identically, which is why it lives in
+ * one place now: minutes until the aeroplane is in the path, and how promising that path
+ * is, for at most the first two.
+ */
+object AirplaneCell {
+
+ /** How many fit in the column before it stops being readable. */
+ private const val SHOWN_AIRPLANES = 2
+
+ fun format(info: AirPlaneReflectionInfo?): String {
+ // Nothing computed for this station yet. Both JavaFX cells ended up blank here, by
+ // different routes: the directed-message one catches the NullPointerException, the
+ // station-list one has no guard at all and lets it escape the value factory.
+ val planes = info?.risingAirplanes ?: return if (info == null) "" else NOTHING_COMING
+
+ if (planes.isEmpty()) return NOTHING_COMING
+
+ return planes.take(SHOWN_AIRPLANES).joinToString(" / ") { plane ->
+ "${plane.arrivingDurationMinutes} (${plane.potential}%)"
+ }
+ }
+
+ /**
+ * Not an empty cell: the operator has to tell "no aeroplane is coming" apart from
+ * "nothing has been computed for this station yet".
+ */
+ private const val NOTHING_COMING = "nil"
+
+ /** `.table-cell-100PercentAP`, identical in both stylesheets: a certain path. */
+ val PURPLE_100 = Color(0xFFF98AFF)
+
+ /** `.table-cell-75PercentAP`: a good one. */
+ val RED_75 = Color(0xFFFA6666)
+
+ /** `.table-cell-50PercentAP`: a fair one. */
+ val ORANGE_50 = Color(0xFFFA9F66)
+
+ /**
+ * The colour of the cell, which is what an aircraft-scatter chance is spotted by — the
+ * numbers are read afterwards.
+ *
+ * Pinned as found: the JavaFX cell factories matched the RENDERED STRING, so only
+ * exactly 100, 75 and 50 are coloured and 85% gets nothing. Colouring by threshold
+ * instead would light up cells the operator has never seen lit, which changes what the
+ * column means at a glance.
+ */
+ fun accentFor(renderedValue: String): CellAccent? = when {
+ renderedValue.contains("100%") -> CellAccent(foreground = PURPLE_100)
+ renderedValue.contains("75%") -> CellAccent(foreground = RED_75)
+ renderedValue.contains("50%") -> CellAccent(foreground = ORANGE_50)
+ else -> null
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/ChatInputRow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/ChatInputRow.kt
new file mode 100644
index 00000000..d814ddf4
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/ChatInputRow.kt
@@ -0,0 +1,308 @@
+package kst4contest.view.compose
+
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.TooltipArea
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.luminance
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.unit.dp
+import kst4contest.model.ChatPreferences
+
+/**
+ * The line the operator types into, and the shortcut buttons above it.
+ *
+ * This is the busiest strip of the window: everything that leaves this station passes
+ * through it. Two details matter more here than anywhere else — the field keeps the
+ * focus, and the caret stays at the end. A button that swallows the focus mid sentence
+ * costs a QSO.
+ */
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+fun ShortcutButtonRow(
+ shortcuts: List,
+ state: ChatInputState,
+ modifier: Modifier = Modifier,
+) {
+ FlowRow(
+ modifier = modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 0.dp),
+ horizontalArrangement = Arrangement.spacedBy(0.dp),
+ verticalArrangement = Arrangement.spacedBy(0.dp),
+ ) {
+ shortcuts.forEach { shortcut ->
+ when (shortcut) {
+ // The two frequency buttons take their text straight from the field beside
+ // them rather than through the variable resolver, which is why they are
+ // not ordinary shortcuts.
+ MYQRG_SHORTCUT -> ShortcutButton(shortcut, accented = true) {
+ state.appendOwnQrg(mainCategory = true)
+ }
+
+ SECOND_QRG_SHORTCUT -> ShortcutButton(shortcut, accented = true) {
+ state.appendOwnQrg(mainCategory = false)
+ }
+
+ else -> ShortcutButton(
+ label = shortcut,
+ accented = shortcut == SET_NAME_MYQRG_SHORTCUT,
+ ) {
+ state.appendShortcut(shortcut)
+ }
+ }
+ }
+ }
+}
+
+/** The two names the shortcut roster gives special meaning to. */
+const val MYQRG_SHORTCUT = "MYQRG"
+const val SECOND_QRG_SHORTCUT = "SECONDQRG"
+
+/** Accented like the two frequency buttons, but an ordinary appending shortcut otherwise. */
+const val SET_NAME_MYQRG_SHORTCUT = "/SETNAME MYQRG"
+
+/**
+ * A shortcut button.
+ *
+ * Three shortcuts wear `.buttonMyQrg1` in both stylesheets — the two frequency buttons and
+ * "/SETNAME MYQRG" — the same green-to-lightgreen gradient the palette already reads as the
+ * accent, so they are told apart by colour rather than by position. Everything else is an
+ * ordinary button.
+ */
+@Composable
+private fun ShortcutButton(label: String, accented: Boolean, onClick: () -> Unit) {
+ if (!accented) {
+ Form.button(text = label, onClick = onClick)
+ return
+ }
+
+ val palette = LocalJavaFxPalette.current
+ Box(
+ modifier = Modifier
+ .heightIn(min = Density.BUTTON_MIN_HEIGHT)
+ .background(
+ brush = if (palette.windowBackground.luminance() < 0.5f) {
+ androidx.compose.ui.graphics.Brush.linearGradient(
+ colors = listOf(Color(0xFF008000), Color(0xFF90EE90))
+ )
+ } else {
+ androidx.compose.ui.graphics.Brush.linearGradient(
+ colors = listOf(Color(0xFF00FFFF), Color(0xFFFF99FF))
+ )
+ },
+ shape = Density.BUTTON_SHAPE
+ )
+ .clickable { onClick() }
+ .padding(Density.BUTTON_CONTENT_PADDING),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = label,
+ color = MYQRG_LABEL_COLOR,
+ style = MaterialTheme.typography.labelLarge,
+ maxLines = 1,
+ )
+ }
+}
+
+/** `-fx-text-fill: #395306` from `.buttonMyQrg1`; a dark olive that reads on the green. */
+private val MYQRG_LABEL_COLOR = Color(0xFF395306)
+
+/**
+ * The send row: the message field, TX, clear, and the three small fields the operator
+ * keeps their own frequencies and antenna heading in.
+ */
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun ChatInputRow(
+ state: ChatInputState,
+ prefs: ChatPreferences,
+ /**
+ * The three fields' live values. Mirrors and not preference reads: the rotator and the
+ * frequency follower move these while the operator is looking at them, and a plain read
+ * would draw the value once and then never again.
+ */
+ ownQrgMain: ObservedValue,
+ ownQrgSecond: ObservedValue,
+ antennaQtf: ObservedValue,
+ modifier: Modifier = Modifier,
+) {
+ val focusRequester = remember { FocusRequester() }
+
+ // Every insertion bumps focusRequests; this is requestFocus() + selectEnd().
+ LaunchedEffect(state.focusRequests) {
+ if (state.focusRequests > 0) {
+ runCatching { focusRequester.requestFocus() }
+ }
+ }
+
+ Row(
+ modifier = modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 2.dp),
+ horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ MessageField(
+ state = state,
+ focusRequester = focusRequester,
+ modifier = Modifier.weight(1f),
+ )
+
+ Form.button(text = "TX", onClick = { state.send() })
+ Form.button(text = "clear", onClick = { state.clear() })
+
+ VerticalRule()
+
+ // Commit on focus loss, as in JavaFX: typing a frequency should not write the
+ // profile on every keystroke.
+ InlineCommittedField(
+ stored = ownQrgMain.value.orEmpty(),
+ onCommit = { prefs.getMYQRGFirstCat().set(it) },
+ tooltip = "Your own frequency in the main category",
+ )
+ InlineCommittedField(
+ stored = ownQrgSecond.value.orEmpty(),
+ onCommit = { prefs.getMYQRGSecondCat().set(it) },
+ tooltip = "Enter the frequency for the second chat category by hand",
+ )
+ InlineCommittedField(
+ stored = antennaQtf.value?.toString().orEmpty(),
+ onCommit = { text -> text.toDoubleOrNull()?.let { prefs.getActualQTF().set(it) } },
+ tooltip = "Your antenna heading (QTF); read from the rotator when one is synced",
+ )
+ }
+}
+
+/**
+ * One of the three narrow fields beside the send controls.
+ *
+ * Written back when the focus leaves, not on every keystroke — the JavaFX fields hung
+ * their write on focusedProperty for the same reason: a half-typed frequency is not a
+ * frequency, and the profile is a file.
+ */
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun InlineCommittedField(
+ stored: String,
+ onCommit: (String) -> Unit,
+ tooltip: String,
+) {
+ var draft by remember(stored) { mutableStateOf(stored) }
+
+ TooltipArea(tooltip = { TooltipChip(tooltip) }) {
+ CompactTextField(
+ value = draft,
+ onValueChange = { draft = it },
+ modifier = Modifier
+ .width(QRG_FIELD_WIDTH)
+ .onFocusChanged { focus ->
+ if (!focus.isFocused && draft != stored) onCommit(draft)
+ },
+ )
+ }
+}
+
+@Composable
+private fun TooltipChip(text: String) {
+ Surface(
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ shape = Density.BUTTON_SHAPE,
+ tonalElevation = 4.dp,
+ ) {
+ Text(
+ text = text,
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+}
+
+private val QRG_FIELD_WIDTH = 70.dp
+
+/**
+ * The message field.
+ *
+ * Enter sends and is consumed, so the newline never reaches the text. Escape clears —
+ * both were on the scene in JavaFX, which means they worked wherever the focus was; here
+ * they are on the field, and the window handles them for the rest of the surface.
+ */
+@Composable
+private fun MessageField(
+ state: ChatInputState,
+ focusRequester: FocusRequester,
+ modifier: Modifier = Modifier,
+) {
+ val scheme = MaterialTheme.colorScheme
+
+ Box(
+ modifier = modifier
+ .heightIn(min = Density.FIELD_MIN_HEIGHT)
+ .border(1.5.dp, scheme.primary, Density.BUTTON_SHAPE)
+ .background(scheme.surface, Density.BUTTON_SHAPE)
+ .padding(horizontal = 6.dp, vertical = 4.dp),
+ contentAlignment = Alignment.CenterStart,
+ ) {
+ BasicTextField(
+ value = state.value,
+ onValueChange = { state.value = it },
+ modifier = Modifier
+ .fillMaxWidth()
+ .focusRequester(focusRequester)
+ .onPreviewKeyEvent { event ->
+ if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
+ when (event.key) {
+ Key.Enter, Key.NumPadEnter -> { state.send(); true }
+ Key.Escape -> { state.clear(); true }
+ else -> false
+ }
+ },
+ singleLine = true,
+ textStyle = LocalTextStyle.current.copy(color = scheme.onSurface),
+ cursorBrush = SolidColor(scheme.primary),
+ )
+ }
+}
+
+/** The vertical separator that sets the frequency fields apart from the send controls. */
+@Composable
+private fun VerticalRule() {
+ Box(
+ Modifier
+ .width(Density.HAIRLINE)
+ .height(Density.FIELD_MIN_HEIGHT)
+ .background(MaterialTheme.colorScheme.outline),
+ )
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/ChatInputState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/ChatInputState.kt
new file mode 100644
index 00000000..2fb7874c
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/ChatInputState.kt
@@ -0,0 +1,199 @@
+package kst4contest.view.compose
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.input.TextFieldValue
+import kst4contest.model.ChatCategory
+import kst4contest.model.ChatMember
+import kst4contest.model.ChatMessage
+
+/**
+ * Everything the send line needs from the rest of the application.
+ *
+ * An interface rather than a handful of lambdas because there are twelve of them and
+ * they belong together; the implementation is the runtime, the test implements it with
+ * recording doubles.
+ */
+interface ChatInputActions {
+
+ /** The station shown in the info panel, set by clicking one. */
+ fun panelSelection(): ChatMember?
+
+ fun tableSelection(): ChatMember?
+
+ fun scoreSelection(): ChatMember?
+
+ /** Replaces %variables% in a template; may legitimately return blank. */
+ fun resolveVariables(template: String, member: ChatMember?): String?
+
+ /** Whether an outgoing private message targets the local station. */
+ fun isAddressedToOwnCallsign(text: String): Boolean
+
+ /** Which chat the message belongs in; the /cq target in the text outranks the selection. */
+ fun resolveCategory(text: String, member: ChatMember?): ChatCategory?
+
+ fun mainCategory(): ChatCategory?
+
+ fun recordOutboundCq(text: String)
+
+ fun requestRecompute(reason: String)
+
+ /** Hands the message to the TX queue; the existing mechanism sends it. */
+ fun queue(message: ChatMessage)
+
+ fun ownQrgMain(): String
+
+ fun ownQrgSecond(): String
+}
+
+/**
+ * The line the operator types into, and the buttons around it.
+ *
+ * Three things go into this field, and they behave differently on purpose. A shortcut
+ * button APPENDS, so several can be strung together into one message. A snippet
+ * REPLACES, because it builds a whole directed message and a half-typed remainder would
+ * only corrupt it. Typing is typing.
+ */
+class ChatInputState(private val actions: ChatInputActions) {
+
+ /** Text and caret together, because every insertion also moves the caret. */
+ var value: TextFieldValue by mutableStateOf(TextFieldValue(""))
+
+ /**
+ * Bumped whenever the field should take the focus back. The Compose counterpart of
+ * requestFocus() + selectEnd(): the operator is mid sentence, and a field that lost
+ * the focus to a button turns the next keystroke into nothing.
+ */
+ var focusRequests: Int by mutableStateOf(0)
+ private set
+
+ /**
+ * Resolves, checks and queues the message, then empties the field.
+ *
+ * The order is the original's, including one inconsistency that is kept because
+ * changing it would change what gets transmitted: the score service's selection is
+ * consulted only after the variables have been resolved, so a station only it knows
+ * about reaches the category decision but not the resolver.
+ */
+ fun send() {
+ var member = actions.panelSelection() ?: actions.tableSelection()
+
+ val resolved = actions.resolveVariables(value.text, member)
+
+ // A variable may legitimately resolve to an empty string; then there is nothing
+ // to send, and the field is emptied anyway so the operator sees it was consumed.
+ if (resolved.isNullOrBlank()) {
+ clear()
+ return
+ }
+
+ if (actions.isAddressedToOwnCallsign(resolved)) {
+ clear()
+ return
+ }
+
+ if (member == null) member = actions.scoreSelection()
+
+ val category = actions.resolveCategory(resolved, member) ?: actions.mainCategory()
+
+ val message = ChatMessage()
+ message.chatCategory = category
+ message.messageText = resolved
+ message.isMessageDirectedToServer = false
+
+ // A /cq starts the reply clock the no-reply tracking runs on.
+ actions.recordOutboundCq(resolved)
+ actions.requestRecompute("outbound-tx")
+
+ actions.queue(message)
+ clear()
+ }
+
+ fun clear() {
+ value = TextFieldValue("")
+ }
+
+ /**
+ * Appends a shortcut's resolved text. A template that resolves to nothing leaves the
+ * field — and the focus — alone.
+ */
+ fun appendShortcut(template: String) {
+ val resolved = actions.resolveVariables("$template ", effectiveStation())
+ if (resolved.isNullOrBlank()) return
+ appendAndFocus(resolved)
+ }
+
+ /**
+ * Appends a snippet chosen from a right-click menu, resolved, with no trailing space
+ * and no /cq prefix.
+ *
+ * The same roster reaches the field three ways and all three differ: Ctrl+1..Ctrl+0
+ * replaces the field with a whole directed message, a right-click appends just the
+ * snippet, and a shortcut button appends its own roster's entry followed by a space.
+ * Collapsing any two of them would change what goes out over the air.
+ */
+ fun appendSnippet(snippet: String) {
+ val resolved = actions.resolveVariables(snippet, effectiveStation())
+ if (resolved.isNullOrBlank()) return
+ appendAndFocus(resolved)
+ }
+
+ /**
+ * Appends the operator's own frequency for one of the two chats. These two buttons
+ * take the value straight from its field rather than through the resolver, which is
+ * why they are not ordinary shortcuts.
+ */
+ fun appendOwnQrg(mainCategory: Boolean) {
+ val qrg = if (mainCategory) actions.ownQrgMain() else actions.ownQrgSecond()
+ appendAndFocus("$qrg ")
+ }
+
+ /**
+ * Replaces the field with "/cq CALL ", resolved.
+ *
+ * Without a selected station there is nobody to direct it at, so nothing happens —
+ * silently, as before, because this runs from Ctrl+1..Ctrl+0 while the operator is
+ * typing and a dialog would be worse than nothing.
+ */
+ fun insertSnippet(snippets: List, index: Int) {
+ val station = effectiveStation() ?: return
+ val callSign = station.callSign ?: return
+ if (index < 0 || index >= snippets.size) return
+
+ val resolved = actions.resolveVariables("/cq $callSign ${snippets[index]}", station)
+ if (resolved.isNullOrBlank()) return
+
+ setAndFocus(resolved)
+ }
+
+ private var lastAutoPreparedSendText: String = ""
+
+ /**
+ * Replaces the field with "/cq CALL ", if the field is empty or contains a previous auto-generated CQ.
+ */
+ fun prepareCq(callSign: String, forceOverwrite: Boolean = false) {
+ if (callSign.isBlank()) return
+ val preparedText = "/cq ${callSign.trim()} "
+
+ val canOverwrite = value.text.isEmpty() || value.text == lastAutoPreparedSendText
+ if (!forceOverwrite && !canOverwrite) {
+ return
+ }
+
+ lastAutoPreparedSendText = preparedText
+ setAndFocus(preparedText)
+ }
+
+ /** The three-step lookup the shortcuts and snippets use. */
+ private fun effectiveStation(): ChatMember? =
+ actions.panelSelection() ?: actions.tableSelection() ?: actions.scoreSelection()
+
+ private fun appendAndFocus(text: String) = setAndFocus(value.text + text)
+
+ private fun setAndFocus(text: String) {
+ value = TextFieldValue(text = text, selection = TextRange(text.length))
+ focusRequests++
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeWindowHost.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeWindowHost.kt
new file mode 100644
index 00000000..db1717de
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/ComposeWindowHost.kt
@@ -0,0 +1,185 @@
+package kst4contest.view.compose
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.unit.isSpecified
+import androidx.compose.runtime.remember
+import androidx.compose.ui.graphics.painter.BitmapPainter
+import androidx.compose.ui.graphics.painter.Painter
+import androidx.compose.ui.res.loadImageBitmap
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.FrameWindowScope
+import androidx.compose.ui.window.Window
+import androidx.compose.ui.window.application
+import androidx.compose.ui.window.rememberWindowState
+import java.util.concurrent.atomic.AtomicBoolean
+
+/**
+ * A Compose window that lives beside the JavaFX application.
+ *
+ * Every such window needs the same four things, and getting any of them wrong has
+ * already cost a defect:
+ *
+ * - **It must not end the process.** `application` defaults to calling `exitProcess`
+ * when its last window closes, which would quit the client when the operator closes
+ * a settings window.
+ * - **It must open at most once.** Two windows writing the same preferences would work
+ * against each other.
+ * - **It must be closable from outside.** A profile switch tears every window down and
+ * rebuilds it. `closeOwnedStages` walks JavaFX Stages and cannot reach a Compose
+ * window; when the settings window survived a switch, its "already open" flag stayed
+ * set and the rebuilt profile silently got no window at all — and that window carries
+ * the Connect button.
+ * - **It must follow a design switch.** The operator can change the design from the
+ * main window's menu while this window is open.
+ *
+ * @param threadName names the thread in a stack dump, which is where one looks when a
+ * window will not close
+ */
+class ComposeWindowHost(private val threadName: String) {
+
+ private val open = AtomicBoolean(false)
+ private val darkModeState = mutableStateOf(false)
+ private val visible = mutableStateOf(true)
+
+ @Volatile
+ private var windowThread: Thread? = null
+
+ /** Whether the window is currently open, so a caller can tell without opening one. */
+ val isOpen: Boolean
+ get() = open.get()
+
+ /** Follows a design switch made in the main window. */
+ fun applyDarkMode(darkMode: Boolean) {
+ darkModeState.value = darkMode
+ }
+
+ /**
+ * Closes the window and waits for it to be gone.
+ *
+ * The wait is not politeness: a profile switch opens the replacement window
+ * immediately afterwards, and it must not be refused by the old one still closing.
+ */
+ fun close() {
+ if (!open.get()) {
+ return
+ }
+
+ visible.value = false
+ windowThread?.join(CLOSE_TIMEOUT_MS)
+ }
+
+ /**
+ * Opens the window, or does nothing when it is already open.
+ *
+ * @param content receives the action that closes the window; only the window itself
+ * can end its own Compose application
+ * @param onResized called with the new size whenever the operator resizes the
+ * window. The JavaFX scenes had width and height listeners writing the
+ * stored size, and the spec's acceptance criterion for this stage names
+ * window sizes explicitly.
+ */
+ fun show(
+ title: String,
+ darkMode: Boolean,
+ baseFontSizeSp: Float,
+ widthDp: Float,
+ heightDp: Float,
+ onResized: ((Float, Float) -> Unit)? = null,
+ /**
+ * Raised above the other windows. The update window needs it: it opens during
+ * startup, and behind the main window it goes unnoticed, which is the one thing
+ * it exists not to do.
+ */
+ alwaysOnTop: Boolean = false,
+ /**
+ * The window's content. A FrameWindowScope receiver, because that is the only scope
+ * a MenuBar can be declared in — without it no window but the main one could ever
+ * carry the shared menu bar.
+ */
+ content: @Composable FrameWindowScope.(close: () -> Unit) -> Unit,
+ ) {
+ darkModeState.value = darkMode
+
+ if (!open.compareAndSet(false, true)) {
+ return
+ }
+
+ visible.value = true
+
+ val thread = Thread({
+ try {
+ application(exitProcessOnExit = false) {
+ if (!visible.value) {
+ /* Nothing rendered means no window, and the application ends. */
+ return@application
+ }
+
+ val windowState = rememberWindowState(
+ width = widthDp.dp,
+ height = heightDp.dp,
+ )
+
+ /*
+ * Written back on every change. The JavaFX scenes did this through
+ * width and height listeners, and the size matters here beyond
+ * comfort: these tables are wider than the default window, so an
+ * operator who widens it once must not have to do it again.
+ */
+ if (onResized != null) {
+ LaunchedEffect(windowState) {
+ snapshotFlow { windowState.size }.collect { size ->
+ if (size.width.isSpecified && size.height.isSpecified) {
+ onResized(size.width.value, size.height.value)
+ }
+ }
+ }
+ }
+
+ Window(
+ onCloseRequest = ::exitApplication,
+ state = windowState,
+ title = title,
+ alwaysOnTop = alwaysOnTop,
+ icon = applicationIcon(),
+ ) {
+ Kst4ContestTheme(
+ darkMode = darkModeState.value,
+ baseFontSizeSp = baseFontSizeSp,
+ ) {
+ content(::exitApplication)
+ }
+ }
+ }
+ } finally {
+ open.set(false)
+ windowThread = null
+ }
+ }, threadName)
+
+ windowThread = thread
+ thread.start()
+ }
+
+ private companion object {
+ const val CLOSE_TIMEOUT_MS = 2_000L
+ }
+}
+
+/**
+ * The application icon, so these windows are not the only ones in the taskbar showing
+ * the default Java cup. Null when the resource is missing rather than failing: a window
+ * without an icon still works.
+ */
+@Composable
+private fun applicationIcon(): Painter? = remember {
+ runCatching {
+ ComposeWindowHost::class.java.getResourceAsStream(APPLICATION_ICON)?.use {
+ BitmapPainter(loadImageBitmap(it))
+ }
+ }.getOrNull()
+}
+
+private const val APPLICATION_ICON = "/icons/kst4contest.png"
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/ConnectionBar.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/ConnectionBar.kt
new file mode 100644
index 00000000..5948ed74
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/ConnectionBar.kt
@@ -0,0 +1,96 @@
+package kst4contest.view.compose
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.runtime.Composable
+import kst4contest.controller.ChatController
+import kst4contest.model.ChatPreferences
+
+/**
+ * The connection state the button bar shows. Derived from the controller rather than
+ * held, because the session also connects and drops outside this window.
+ */
+enum class ConnectionState { DISCONNECTED, CONNECTED_NOT_LOGGED_IN, LOGGED_IN }
+
+/**
+ * The button bar at the bottom of the settings window.
+ *
+ * This bar is the reason the settings window exists at startup: in the JavaFX client
+ * it carried the Connect button, which is the one place where `ChatController.execute`
+ * is called and therefore the only way into the chat. Saving and closing sit next to
+ * it because that is where the operator has always found them.
+ */
+class ConnectionBarState(
+ private val prefs: ChatPreferences,
+ private val controller: ChatController,
+) {
+
+ val connectionState: ConnectionState
+ get() = when {
+ controller.isConnectedAndLoggedIn -> ConnectionState.LOGGED_IN
+ controller.isConnectedAndNOTLoggedIn -> ConnectionState.CONNECTED_NOT_LOGGED_IN
+ else -> ConnectionState.DISCONNECTED
+ }
+
+ /**
+ * The label of the Connect button, which names the chat the station will land in —
+ * and both chats when the second one is enabled, so the operator sees before
+ * pressing it what they are logging into.
+ */
+ val connectButtonText: String
+ get() {
+ val main = prefs.getLoginChatCategoryMain()
+ ?: return "Connect"
+
+ var label = "Connect to " + main.getChatCategoryName(main.categoryNumber)
+ val second = prefs.getLoginChatCategorySecond()
+
+ if (prefs.isLoginToSecondChatEnabled() && second != null) {
+ label += " & " + second.getChatCategoryName(second.categoryNumber)
+ }
+
+ return label
+ }
+
+ /** Only a disconnected session can connect; the JavaFX button locked itself. */
+ val canConnect: Boolean
+ get() = connectionState != ConnectionState.LOGGED_IN
+
+ /** Disconnecting needs a session that is actually logged in. */
+ val canDisconnect: Boolean
+ get() = connectionState == ConnectionState.LOGGED_IN
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+fun ConnectionBar(
+ state: ConnectionBarState,
+ onConnect: () -> Unit,
+ onSave: () -> Unit,
+ onApplyAndClose: () -> Unit,
+ onDisconnectAndCloseChat: () -> Unit,
+ onDisconnectOnly: () -> Unit,
+) {
+ /*
+ * Order as in the JavaFX HBox: connect, save, apply, disconnect and close, and
+ * disconnect only. Operators reach for these by position.
+ */
+ /*
+ * A wrapping row, not a plain one. Five buttons do not fit a narrow window, and a
+ * plain Row does not wrap: it squeezes the last children instead. A button squeezed
+ * to a few pixels wraps its own label to one letter per line and grows into a tall
+ * coloured bar that eats the whole window height — which is exactly what happened.
+ */
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
+ verticalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
+ ) {
+ Form.button(state.connectButtonText, enabled = state.canConnect, onClick = onConnect)
+ Form.button("Save settings", onClick = onSave)
+ Form.button("Apply/Close prefs", onClick = onApplyAndClose)
+ Form.button("Disconnect & close Chat", onClick = onDisconnectAndCloseChat)
+ Form.button("Disconnect", enabled = state.canDisconnect, onClick = onDisconnectOnly)
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTable.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTable.kt
new file mode 100644
index 00000000..ba76feef
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTable.kt
@@ -0,0 +1,381 @@
+package kst4contest.view.compose
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.ui.Alignment
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.snapshots.SnapshotStateList
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import kst4contest.observe.SimpleRoster
+
+/**
+ * One row of an [EditableListState].
+ *
+ * The key is what a LazyColumn is keyed by, and it must survive an insertion
+ * above the row: keying by list index makes every row below an insertion change
+ * identity, which moves the selection. Keying by text would collapse duplicates,
+ * and the shortcut and snippet lists can hold the same text twice.
+ */
+data class ListEntry(val key: Long, val text: String)
+
+/**
+ * What committing an edit did to the list.
+ *
+ * A commit is the counterpart of the JavaFX `setOnEditCommit` handler, which ran
+ * when the operator left the cell — not on every keystroke. That distinction is
+ * what makes a rule such as "fold a callsign to its base call" usable: applying
+ * it per keystroke would fight the typist.
+ */
+sealed interface CommitOutcome {
+
+ /** The entry now holds this text, which a rule may have rewritten. */
+ data class Stored(val text: String) : CommitOutcome
+
+ /** The entry is gone: clearing the text was how the operator removed one. */
+ data object Removed : CommitOutcome
+
+ /**
+ * The edit was refused and the stored text stands. The message is the one the
+ * JavaFX table showed in an alert before calling `refresh()` on itself.
+ */
+ data class Refused(val message: String) : CommitOutcome
+
+ /** The row no longer exists: a pending edit can outlive its row. */
+ data object Ignored : CommitOutcome
+}
+
+/**
+ * Decides what a committed edit becomes. The default accepts the text as typed
+ * and treats a blank one as a removal; the monitored-callsign list replaces it
+ * with the normalize-and-reject-duplicates rule the JavaFX table had.
+ */
+fun interface EntryRule {
+
+ /**
+ * @param candidate text as the operator typed it
+ * @param index row being committed
+ * @param others texts of the other rows in order, the edited row excluded, so a
+ * duplicate check does not compare a row against itself
+ */
+ fun decide(candidate: String, index: Int, others: List): CommitOutcome
+
+ companion object {
+
+ /** Accept as typed; a blank text removes the entry. */
+ val asTyped: EntryRule = EntryRule { candidate, _, _ ->
+ if (candidate.isBlank()) CommitOutcome.Removed else CommitOutcome.Stored(candidate)
+ }
+ }
+}
+
+/**
+ * Editing state of a single-column string list, free of Compose so the rules are
+ * testable without a toolkit.
+ *
+ * Reproduces what the JavaFX tables in the settings window did: a new entry goes
+ * to the top and becomes the selection, and an entry edited to blank disappears —
+ * that was how the operator removed one
+ * (Kst4ContestApplication, ShortCol.setOnEditCommit).
+ */
+class EditableListState(
+ initial: List,
+ private val rule: EntryRule = EntryRule.asTyped,
+) {
+
+ private var nextKey: Long = 0
+
+ /*
+ * Compose state, so a row redraws when the selection moves off it or its text
+ * changes. Plain collections left the user interface guessing: select() is called
+ * on a focus change without any other notification, and the row that lost the
+ * selection kept its accent border.
+ */
+ private val items: SnapshotStateList =
+ mutableStateListOf().apply {
+ initial.forEach { add(ListEntry(nextKey++, it)) }
+ }
+
+ val entries: List
+ get() = items.toList()
+
+ val texts: List
+ get() = items.map { it.text }
+
+ var selectedIndex: Int? by mutableStateOf(null)
+ private set
+
+ fun select(index: Int?) {
+ selectedIndex = index?.takeIf { it in items.indices }
+ }
+
+ fun addAtTop(entry: String) {
+ items.add(0, ListEntry(nextKey++, entry))
+ selectedIndex = 0
+ }
+
+ /**
+ * Appends an entry. The monitoring list grew this way: its Add button asked for
+ * a callsign in a dialog and called `add` on the roster, which appends.
+ */
+ fun addAtBottom(entry: String) {
+ items.add(ListEntry(nextKey++, entry))
+ selectedIndex = items.lastIndex
+ }
+
+ /** An index outside the list is ignored: a pending edit can outlive its row. */
+ fun updateAt(index: Int, text: String) {
+ if (index !in items.indices) {
+ return
+ }
+ if (text.isBlank()) {
+ removeAt(index)
+ return
+ }
+ items[index] = items[index].copy(text = text)
+ }
+
+ /**
+ * Commits an edit through the list's [EntryRule]. This is what the user interface
+ * calls when the operator leaves a row or presses Enter, mirroring
+ * `setOnEditCommit`; [updateAt] stays the unguarded path.
+ */
+ fun commitAt(index: Int, candidate: String): CommitOutcome {
+ if (index !in items.indices) {
+ return CommitOutcome.Ignored
+ }
+
+ val others = items.filterIndexed { position, _ -> position != index }.map { it.text }
+
+ return when (val verdict = rule.decide(candidate, index, others)) {
+ is CommitOutcome.Stored -> {
+ items[index] = items[index].copy(text = verdict.text)
+ verdict
+ }
+
+ CommitOutcome.Removed -> {
+ removeAt(index)
+ verdict
+ }
+
+ else -> verdict
+ }
+ }
+
+ fun removeAt(index: Int) {
+ if (index !in items.indices) {
+ return
+ }
+ items.removeAt(index)
+ selectedIndex = selectedIndex?.let { current ->
+ if (current >= items.size) items.indices.lastOrNull() else current
+ }
+ }
+
+ /**
+ * Moves the selected entry by [offset] positions and keeps it selected, the way
+ * `moveSelectedTableEntry` did. Returns false when there is no selection or the
+ * target position is outside the list, so the caller can skip its follow-up work.
+ */
+ fun moveSelected(offset: Int): Boolean {
+ val current = selectedIndex ?: return false
+ val target = current + offset
+
+ if (current !in items.indices || target !in items.indices) {
+ return false
+ }
+
+ items.add(target, items.removeAt(current))
+ selectedIndex = target
+ return true
+ }
+
+ /**
+ * Fills the given roster. Deliberately setAll and not a field swap: replacing
+ * the roster would orphan every listener registered on it, which is the rule
+ * the ChatPreferences setters have followed since Etappe 2.
+ */
+ fun commitTo(roster: SimpleRoster) {
+ roster.setAll(texts)
+ }
+}
+
+/**
+ * An editable single-column list, the Compose counterpart of the three
+ * TableView tables in the settings window.
+ *
+ * Column widths, sorting and row styling are deliberately absent: no consumer in
+ * the settings window needs them. They belong with the main-window tables, where
+ * TableLayoutManager persists widths against stable column ids.
+ *
+ * Editing is committed when the operator leaves a row or presses Enter, not on
+ * every keystroke. That is what the JavaFX `TextFieldTableCell` did: the typed
+ * text lived in the cell editor until it was committed, which is why a rule could
+ * reject it and put the stored value back.
+ *
+ * @param onChanged called after every committed change — add, edit, removal, move.
+ * The lists write through, so this is where the roster is filled.
+ * @param onRefused shown to the operator when a rule refuses an edit; the JavaFX
+ * tables opened an alert here.
+ * @param onAdd replaces the default "insert the template at the top" behaviour.
+ * The monitoring list needs it: its Add button asked for a callsign first.
+ * @param listHeight the list is a bounded, scrolling area like the TableView it
+ * replaces. It also keeps a LazyColumn out of an unbounded parent scroll,
+ * which Compose rejects.
+ */
+@Composable
+fun DataTable(
+ state: EditableListState,
+ addButtonText: String,
+ newEntryTemplate: String,
+ onChanged: () -> Unit,
+ modifier: Modifier = Modifier,
+ onRefused: (String) -> Unit = {},
+ onAdd: (() -> Unit)? = null,
+ showMoveButtons: Boolean = false,
+ listHeight: Dp = 220.dp,
+) {
+ var revision by remember { mutableStateOf(0) }
+ val focusOnNewRow = remember { FocusRequester() }
+
+ fun changed() {
+ revision++
+ onChanged()
+ }
+
+ Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ @Suppress("UNUSED_EXPRESSION") revision // read so edits recompose the list
+
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(listHeight)
+ .padding(vertical = 4.dp),
+ ) {
+ itemsIndexed(state.entries, key = { _, entry -> entry.key }) { index, entry ->
+ /*
+ * Keyed on the revision too, so a refused edit is visibly put back:
+ * the stored text did not change, and without the revision the draft
+ * would keep the text the rule just rejected.
+ */
+ var draft by remember(entry.key, entry.text, revision) { mutableStateOf(entry.text) }
+ var hadFocus by remember(entry.key) { mutableStateOf(false) }
+
+ fun commit() {
+ when (val outcome = state.commitAt(index, draft)) {
+ is CommitOutcome.Refused -> {
+ changed()
+ onRefused(outcome.message)
+ }
+
+ CommitOutcome.Ignored -> Unit
+
+ else -> changed()
+ }
+ }
+
+ val isSelected = state.selectedIndex == index
+
+ /*
+ * The selected row is marked on the field's own outline rather than by a
+ * second frame around it: every row here already is a bordered text
+ * field, and a frame inside a frame reads as noise instead of as a
+ * selection. The tinted background carries it the rest of the way.
+ */
+ CompactTextField(
+ value = draft,
+ onValueChange = { draft = it },
+ borderColor = if (isSelected) MaterialTheme.colorScheme.primary else null,
+ borderThickness = if (isSelected) 2.dp else null,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 1.dp)
+ .background(
+ if (isSelected) {
+ MaterialTheme.colorScheme.secondaryContainer
+ } else {
+ Color.Transparent
+ }
+ )
+ .onFocusChanged { focusState ->
+ if (focusState.isFocused) {
+ state.select(index)
+ } else if (hadFocus) {
+ commit()
+ }
+ hadFocus = focusState.isFocused
+ }
+ .onPreviewKeyEvent { event ->
+ if (event.type == KeyEventType.KeyDown &&
+ (event.key == Key.Enter || event.key == Key.NumPadEnter)
+ ) {
+ commit()
+ true
+ } else {
+ false
+ }
+ }
+ .let { if (index == 0) it.focusRequester(focusOnNewRow) else it },
+ )
+ }
+ }
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Form.button(addButtonText) {
+ if (onAdd != null) {
+ onAdd()
+ revision++
+ return@button
+ }
+
+ state.addAtTop(newEntryTemplate)
+ changed()
+ /*
+ * Focus is requested explicitly. The JavaFX table opened the new row
+ * for editing; hoping Compose does the same would repeat the focus
+ * defects of Etappe 2, where two of three bugs were focus bugs.
+ */
+ runCatching { focusOnNewRow.requestFocus() }
+ }
+
+ if (showMoveButtons) {
+ Form.button("Move selected down") { if (state.moveSelected(1)) changed() }
+ Form.button("Move selected up") { if (state.moveSelected(-1)) changed() }
+ }
+
+ Text(
+ "Clear the text of an entry to remove it.",
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableState.kt
new file mode 100644
index 00000000..b14293b4
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableState.kt
@@ -0,0 +1,383 @@
+package kst4contest.view.compose
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.graphics.Color
+import java.util.IdentityHashMap
+
+/**
+ * How a cell is painted beyond its text. Both colours are optional; null keeps the
+ * table's own.
+ */
+data class CellAccent(val background: Color? = null, val foreground: Color? = null)
+
+/**
+ * One column of a data table.
+ *
+ * The shape is taken from what the station list of Etappe 5 actually does, not from
+ * what the two monitor tables of Etappe 4 need. Those use the simple cases only —
+ * cutting the interface to fit them would produce one the station list cannot use,
+ * which is how the single-column DataTable of Etappe 3b came about.
+ *
+ * @param id stable across releases; the stored column widths are keyed by it, so
+ * renaming one loses the operator's layout
+ * @param value what the cell shows
+ * @param tooltip built from the row **and** the displayed value, because the JavaFX
+ * cells do exactly that — the QRA column explains its grid status that way
+ * @param accent colouring per cell, again from row and value together
+ * @param comparator sorts this column; without one the displayed text decides
+ */
+class DataColumn(
+ val id: String,
+ val title: String,
+ val value: (T) -> String,
+ val tooltip: ((T, String) -> String)? = null,
+ val accent: ((T, String) -> CellAccent?)? = null,
+ val comparator: Comparator? = null,
+ val weight: Float = 1f,
+ /**
+ * Columns sharing a group are drawn under one heading, as the JavaFX table drew
+ * its worked columns. Without it ten columns headed "wkdany 50 70 144 432 23 13
+ * 9 6 3" say nothing about what they mean.
+ */
+ val group: String? = null,
+)
+
+/**
+ * Gives every row a key that survives a refresh.
+ *
+ * There are two kinds of list here and they need different answers:
+ *
+ * - **Entity lists** — the station list, the worked-stations list. A row *is* a
+ * station, and the same station comes back updated. Its own key is the identity:
+ * [RowKeys.byValue] with the raw callsign.
+ * - **Event lists** — the DX cluster and the QSO monitor. A row is something that
+ * happened. Two identical messages a second apart are genuinely two messages, so a
+ * key built from their fields would merge them: [RowKeys.byReference].
+ *
+ * There is deliberately no default. In Etappe 2 the visible defects came from row
+ * identity — a key that was the position shifted on every insert at the top, and one
+ * that was the text collapsed duplicates. Making the caller decide keeps that choice
+ * where the knowledge is.
+ */
+interface RowKeySource {
+
+ fun keyOf(row: T): Any
+
+ /**
+ * Lets a source that remembers keys forget the rows that have gone.
+ *
+ * Called whenever the rows are replaced. Both stores behind these tables are
+ * capped on purpose — ten thousand cluster messages, thirty thousand chat messages
+ * — so that memory cannot grow without bound. A key map holding a strong reference
+ * to every row it ever saw would undo that cap from the outside.
+ */
+ fun retainOnly(rows: List) {}
+
+ /** How many keys are being remembered. Zero for a source that remembers none. */
+ fun size(): Int = 0
+}
+
+object RowKeys {
+
+ /** For rows that carry their own identity; nothing is remembered. */
+ fun byValue(key: (T) -> String): RowKeySource = object : RowKeySource {
+ override fun keyOf(row: T): Any = key(row)
+ }
+
+ /**
+ * For append-only lists whose rows carry no identity.
+ *
+ * Keys are handed out on first sight and remembered by object reference, which
+ * holds because the core rosters hand back the same instances on every snapshot.
+ * A row that really is a new object gets a new key — which is the point.
+ *
+ * Synchronised throughout: the rows are replaced from the JavaFX thread while
+ * Compose draws on its own, and an IdentityHashMap torn between two threads hands
+ * out a duplicate key (the list then throws) or loses one (the row jumps). Both
+ * were seen in Etappe 2.
+ */
+ fun byReference(): RowKeySource = object : RowKeySource {
+
+ private val keys = IdentityHashMap()
+ private var next = 0L
+
+ override fun keyOf(row: T): Any = synchronized(keys) {
+ keys.getOrPut(row) { next++ }
+ }
+
+ override fun retainOnly(rows: List) {
+ synchronized(keys) {
+ val surviving = IdentityHashMap()
+ rows.forEach { surviving[it] = true }
+ keys.keys.retainAll { surviving.containsKey(it) }
+ }
+ }
+
+ override fun size(): Int = synchronized(keys) { keys.size }
+ }
+}
+
+/**
+ * Where column widths are kept between sessions.
+ *
+ * An interface rather than a direct call to ChatPreferences, so the rules can be
+ * tested without one. The real implementation is two lines over
+ * `getTableColumnWidth`/`setTableColumnWidth` plus `LayoutAutosave.requestSave`:
+ * that storage is toolkit-free and stays as it is, while the JavaFX glue around it
+ * does not come across.
+ */
+interface ColumnWidthStore {
+
+ /** The stored width, or null when this column was never sized. */
+ fun width(tableId: String, columnId: String): Double?
+
+ fun setWidth(tableId: String, columnId: String, width: Double)
+
+ /** Asks for a debounced save; widths must not be written per pixel of a drag. */
+ fun requestSave()
+}
+
+/**
+ * The state of a multi-column table: its rows, its sorting and its selection.
+ *
+ * Free of Compose UI so the rules can be tested, but the three mutable properties are
+ * Compose state: a table bound to plain properties does not repaint, and with live
+ * data that shows up as a stale display rather than as an unresponsive one — harder
+ * to notice and worse in effect.
+ */
+class DataTableState(
+ val columns: List>,
+ private val rowKey: RowKeySource,
+ /** Part of the stored layout: renaming it loses the operator's column sizing. */
+ private val tableId: String = "",
+ private val widths: ColumnWidthStore? = null,
+) {
+
+ /**
+ * The order the columns are shown in, by id.
+ *
+ * Within the session only. The JavaFX TableView let the operator drag a column
+ * elsewhere but never stored where they put it — TableLayoutManager persists widths
+ * and ChatPreferences has no column order at all — and 1.50 is a functionally equal
+ * port, not an improvement on it.
+ */
+ private var order: List by mutableStateOf(columns.map { it.id })
+
+ /** The columns in the order they are shown. */
+ val orderedColumns: List>
+ get() = order.mapNotNull { id -> columns.firstOrNull { it.id == id } }
+
+ /**
+ * Moves a column to another position.
+ *
+ * A target outside the table is clamped rather than refused: a drag that overshoots
+ * the last header means "put it at the end", which is what the operator did with
+ * the pointer.
+ */
+ fun moveColumn(columnId: String, toIndex: Int) {
+ val from = order.indexOf(columnId)
+
+ if (from < 0) {
+ return
+ }
+
+ val target = toIndex.coerceIn(0, order.size - 1)
+
+ if (target == from) {
+ return
+ }
+
+ order = order.toMutableList().apply { add(target, removeAt(from)) }
+ }
+
+ /** Bumped on a resize so a table reading widths repaints. */
+ private var widthRevision: Int by mutableStateOf(0)
+
+ /**
+ * The stored width of a column, or null when it was never sized.
+ *
+ * Null rather than a made-up number: the caller falls back to the column's own
+ * weight, which is where the sensible default lives.
+ */
+ fun widthOf(columnId: String): Double? {
+ @Suppress("UNUSED_EXPRESSION") widthRevision
+
+ return widths?.width(tableId, columnId)
+ }
+
+ /**
+ * Stores a new width for a column of this table.
+ *
+ * A width of zero or less is refused. A drag past the left edge would otherwise
+ * store a column the operator cannot grab again, and the sizing survives restarts.
+ */
+ fun resizeColumn(columnId: String, width: Double) {
+ if (width <= 0.0 || columns.none { it.id == columnId }) {
+ return
+ }
+
+ widths?.let { store ->
+ store.setWidth(tableId, columnId, width)
+ store.requestSave()
+ widthRevision++
+ }
+ }
+
+ var rows: List by mutableStateOf(emptyList())
+ private set
+
+ var sortedColumnId: String? by mutableStateOf(null)
+ private set
+
+ var sortAscending: Boolean by mutableStateOf(true)
+ private set
+
+ private var selectedKey: Any? by mutableStateOf(null)
+
+ private var sortedCache: List? = null
+
+ private var _rowFilter by mutableStateOf<((T) -> Boolean)?>(null, androidx.compose.runtime.neverEqualPolicy())
+
+ /** Optional filter predicate. If set, [visibleRows] will only include matching rows. */
+ var rowFilter: ((T) -> Boolean)?
+ get() = _rowFilter
+ set(value) {
+ _rowFilter = value
+ invalidateOrder()
+ }
+
+ /** Incremented to force Compose to redraw visible rows when mutable backend objects change without list structural changes. */
+ var contentRevision by mutableStateOf(0)
+ private set
+
+ fun forceRedraw() {
+ contentRevision++
+ }
+
+ /**
+ * The rows in the order they are shown.
+ *
+ * Computed once per change and then handed back. This is read on every
+ * recomposition, and sorting here would mean sorting up to thirty thousand rows
+ * per inbound message on the thread that draws — with a column that has no
+ * comparator calling its value function once per comparison on top.
+ */
+ val visibleRows: List
+ get() {
+ /*
+ * Read before the cache is consulted, and that order is the whole point.
+ * Compose repaints a composable when state it *read* changes; a cache that
+ * returned early stopped reading these, so the next recomposition left the
+ * table subscribed to nothing and later rows never reached the screen. It
+ * showed as a monitor window sitting empty beside a filling JavaFX tab,
+ * redrawing only when an unrelated click forced it.
+ */
+ val currentRows = rows
+ val currentFilter = rowFilter
+ val currentColumn = sortedColumnId
+ val ascending = sortAscending
+
+ sortedCache?.let { return it }
+
+ val filteredRows = if (currentFilter != null) currentRows.filter(currentFilter) else currentRows
+
+ val column = columns.firstOrNull { it.id == currentColumn }
+ val sorted = if (column == null) {
+ filteredRows
+ } else {
+ /*
+ * The displayed text is read once per row rather than once per
+ * comparison, and sortedBy is stable, so rows that compare equal keep
+ * the order they arrived in. Etappe 2 lost that: the JavaFX SortedList
+ * inserted by binary search and put ties wherever it landed, which made
+ * rows jump.
+ */
+ val order = column.comparator
+ ?: compareBy(currentRows.associateWith { column.value(it) }::getValue)
+
+ if (ascending) {
+ currentRows.sortedWith(order)
+ } else {
+ currentRows.sortedWith(order.reversed())
+ }
+ }
+
+ sortedCache = sorted
+ return sorted
+ }
+
+ private fun invalidateOrder() {
+ sortedCache = null
+ }
+
+ /** The selected row, or null when nothing is selected or it has gone. */
+ val selectedRow: T?
+ get() = selectedKey?.let { key -> rows.firstOrNull { rowKey.keyOf(it) == key } }
+
+ /**
+ * Replaces the rows.
+ *
+ * The selection is kept **by key**, not by position: a station that moved because
+ * something above it was inserted is still the station the operator picked. When
+ * the selected row is gone the selection is dropped rather than sliding onto its
+ * neighbour, which would silently point the operator at someone else.
+ */
+ fun replaceRows(newRows: List) {
+ rows = newRows
+ rowKey.retainOnly(newRows)
+ invalidateOrder()
+
+ val key = selectedKey
+ if (key != null && newRows.none { rowKey.keyOf(it) == key }) {
+ selectedKey = null
+ }
+ }
+
+ fun select(row: T?) {
+ selectedKey = row?.let(rowKey::keyOf)
+ }
+
+ /**
+ * The key a row is drawn under. Its identity, never its position: a positional key
+ * reshuffles every row whenever something arrives at the top, which in a live list
+ * is constant.
+ */
+ fun keyOf(row: T): Any = rowKey.keyOf(row)
+
+ fun isSelected(row: T): Boolean = selectedKey != null && rowKey.keyOf(row) == selectedKey
+
+ /**
+ * Sorts by a column, cycling ascending, descending, unsorted.
+ *
+ * The third click is not a flourish: JavaFX TableView gives that cycle for free, and
+ * without it an operator who sorted the message list once could never get back to
+ * arrival order — newest at the bottom — short of restarting.
+ *
+ * An unknown column is ignored rather than clearing the sorting: it can only come
+ * from a stored layout that names a column this release no longer has.
+ */
+ fun toggleSort(columnId: String) {
+ if (columns.none { it.id == columnId }) {
+ return
+ }
+
+ when {
+ sortedColumnId != columnId -> {
+ sortedColumnId = columnId
+ sortAscending = true
+ }
+
+ sortAscending -> sortAscending = false
+
+ // Third click: back to the order the rows arrived in.
+ else -> {
+ sortedColumnId = null
+ sortAscending = true
+ }
+ }
+
+ invalidateOrder()
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableView.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableView.kt
new file mode 100644
index 00000000..16dd5ce6
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableView.kt
@@ -0,0 +1,440 @@
+package kst4contest.view.compose
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.detectHorizontalDragGestures
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.ui.unit.Dp
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.HorizontalScrollbar
+import androidx.compose.foundation.LocalScrollbarStyle
+import androidx.compose.foundation.TooltipArea
+import androidx.compose.foundation.VerticalScrollbar
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.rememberScrollbarAdapter
+import androidx.compose.material3.Surface
+import androidx.compose.ui.input.pointer.PointerIcon
+import androidx.compose.ui.input.pointer.pointerHoverIcon
+import androidx.compose.ui.text.style.TextOverflow
+import java.awt.Cursor
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+
+/**
+ * A multi-column table.
+ *
+ * The rules — row identity, selection across a refresh, stable sorting, stored widths
+ * — live in [DataTableState] and are tested there. What is here is only the drawing
+ * and the gestures, because nothing that draws is covered by a test in this release.
+ *
+ * Rows are keyed by the state's own row key, never by their position: the lists this
+ * table serves are live, and a positional key reshuffles every row whenever something
+ * arrives at the top.
+ *
+ * @param rowStyle colours a whole row, as the station list does from its priority
+ * score. Not used by the monitor tables, and deliberately still present: the
+ * interface is cut for the station list of Etappe 5.
+ */
+/*
+ * TooltipArea is experimental in Compose foundation. The opt-in is deliberate: it is
+ * the supported way to reach the hover text the JavaFX cells had, and losing that
+ * loses the only way to read a value that does not fit its column.
+ */
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun DataTableView(
+ state: DataTableState,
+ modifier: Modifier = Modifier,
+ rowStyle: ((T) -> CellAccent?)? = null,
+ onRowClick: ((T) -> Unit)? = null,
+) {
+ val density = LocalDensity.current
+
+ val horizontal = rememberScrollState()
+ val vertical = rememberLazyListState()
+
+ /*
+ * These lists are newest-first, and a LazyColumn anchors to the item it was
+ * showing — so every arrival at the top pushes the view down and the operator
+ * drifts away from the newest line without touching anything.
+ *
+ * The rule: stay at the top while the operator is at the top, and leave them alone
+ * once they have scrolled down to read something. Yanking a reader back to the top
+ * on every inbound message would be worse than drifting.
+ */
+ var stickToTop by remember { mutableStateOf(true) }
+
+ LaunchedEffect(vertical) {
+ snapshotFlow { vertical.isScrollInProgress }.collect { scrolling ->
+ /* Only a scroll the operator made ends; a prepend does not scroll. */
+ if (!scrolling) {
+ stickToTop = vertical.firstVisibleItemIndex == 0 &&
+ vertical.firstVisibleItemScrollOffset == 0
+ }
+ }
+ }
+
+ val newestKey = state.visibleRows.firstOrNull()?.let { state.keyOf(it) }
+
+ LaunchedEffect(newestKey) {
+ if (stickToTop && newestKey != null) {
+ vertical.scrollToItem(0)
+ }
+ }
+
+ /*
+ * Both directions scroll. The columns of these two tables need about 890dp and
+ * 1000dp, and the stored window width starts at 700: without this the last columns
+ * are simply unreachable, and so are the handles that would narrow them. The JavaFX
+ * tables had a horizontal scrollbar.
+ */
+ BoxWithConstraints(modifier = modifier.fillMaxSize()) {
+ val slack = unusedWidth(state, maxWidth)
+ val lastColumnId = state.orderedColumns.lastOrNull()?.id
+
+ Column(modifier = Modifier.fillMaxSize()) {
+ /*
+ * A second header row above the first, spanning the columns that share a
+ * group. The station list needs it: ten columns headed "wkdany 50 70 144 432
+ * 23 13 9 6 3" say nothing without the word "worked" over them.
+ */
+ val groups = state.orderedColumns.map { it.group }
+
+ if (groups.any { it != null }) {
+ Row(
+ modifier = Modifier
+ .horizontalScroll(horizontal)
+ .background(MaterialTheme.colorScheme.surfaceVariant)
+ .heightIn(min = Density.ROW_MIN_HEIGHT),
+ ) {
+ var index = 0
+
+ while (index < state.orderedColumns.size) {
+ val group = groups[index]
+ var span = 1
+
+ while (index + span < groups.size && groups[index + span] == group && group != null) {
+ span++
+ }
+
+ val width = (index until index + span).sumOf { position ->
+ val column = state.orderedColumns[position]
+ val stored = state.widthOf(column.id)
+ val own = stored ?: (DEFAULT_COLUMN_WIDTH * column.weight).value.toDouble()
+ own + Density.COLUMN_HANDLE_WIDTH.value
+ }
+
+ Box(
+ modifier = Modifier.width(width.dp).padding(horizontal = 4.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (group != null) {
+ Text(
+ group,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ )
+ }
+ }
+
+ index += span
+ }
+ }
+
+ HorizontalDivider(thickness = Density.HAIRLINE)
+ }
+
+ /*
+ * IntrinsicSize.Min, so the fillMaxHeight of the cells and the grab handles
+ * resolves against the header's own content instead of against the whole
+ * table. Without it the header took the entire height and the handles became
+ * full-length bars down the window.
+ */
+ Row(
+ modifier = Modifier
+ .horizontalScroll(horizontal)
+ .background(MaterialTheme.colorScheme.surfaceVariant)
+ .height(IntrinsicSize.Min)
+ .heightIn(min = Density.ROW_MIN_HEIGHT),
+ ) {
+ state.orderedColumns.forEachIndexed { index, column ->
+ val sorted = state.sortedColumnId == column.id
+
+ /*
+ * Click to sort, drag sideways to reorder — the two gestures the JavaFX
+ * header had. The drag is measured in whole column steps rather than
+ * pixels, so a column moves once per column crossed instead of
+ * flickering between two places.
+ */
+ var dragged by remember(column.id) { mutableStateOf(0f) }
+
+ Box(
+ modifier = Modifier
+ .columnWidth(state, column, if (column.id == lastColumnId) slack else 0.dp)
+ .fillMaxHeight()
+ .clickable { state.toggleSort(column.id) }
+ .pointerInput(column.id, index) {
+ detectHorizontalDragGestures(
+ onDragEnd = { dragged = 0f },
+ onDragCancel = { dragged = 0f },
+ ) { _, dragAmount ->
+ dragged += dragAmount
+ val step = with(density) { DEFAULT_COLUMN_WIDTH.toPx() } / 2f
+
+ if (dragged > step) {
+ state.moveColumn(column.id, index + 1)
+ dragged = 0f
+ } else if (dragged < -step) {
+ state.moveColumn(column.id, index - 1)
+ dragged = 0f
+ }
+ }
+ }
+ .pointerHoverIcon(PointerIcon(Cursor(Cursor.MOVE_CURSOR)))
+ .padding(horizontal = 4.dp, vertical = 3.dp),
+ contentAlignment = Alignment.CenterStart,
+ ) {
+ Text(
+ column.title + if (sorted) (if (state.sortAscending) " ▲" else " ▼") else "",
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ )
+ }
+
+ /*
+ * The grab handle between two headers. Two pixels wide on purpose: the
+ * JavaFX table put the same gesture on the column border, and operators
+ * resize these tables constantly during a contest.
+ */
+ /*
+ * A hairline to look at, a wider area to grab. The JavaFX column border
+ * was a thin line; a 4dp bar in the outline colour reads as a wall
+ * between the columns.
+ */
+ Box(
+ modifier = Modifier
+ .width(Density.COLUMN_HANDLE_WIDTH)
+ .fillMaxHeight()
+ .pointerHoverIcon(PointerIcon(Cursor(Cursor.E_RESIZE_CURSOR)))
+ .pointerInput(column.id) {
+ detectHorizontalDragGestures { _, dragAmount ->
+ /*
+ * The drag is in pixels, the stored width is in dp, and
+ * the stored value survives a restart. Mixing the two
+ * doubles a column on its first touch on a HiDPI screen
+ * and writes that into preferences.xml.
+ */
+ val deltaDp = with(density) { dragAmount.toDp().value }
+ val current = state.widthOf(column.id)
+ ?: (DEFAULT_COLUMN_WIDTH * column.weight).value.toDouble()
+
+ state.resizeColumn(column.id, current + deltaDp)
+ }
+ },
+ contentAlignment = Alignment.Center,
+ ) {
+ Box(
+ modifier = Modifier
+ .width(Density.HAIRLINE)
+ .fillMaxHeight()
+ .background(MaterialTheme.colorScheme.outline),
+ )
+ }
+ }
+ }
+
+ HorizontalDivider(thickness = Density.HAIRLINE)
+
+ Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
+ LazyColumn(
+ state = vertical,
+ modifier = Modifier
+ .fillMaxSize()
+ .horizontalScroll(horizontal)
+ .padding(end = Density.SCROLLBAR_GUTTER),
+ ) {
+ itemsIndexed(state.visibleRows, key = { _, row -> state.keyOf(row) }) { index, row ->
+ val selected = state.isSelected(row)
+ val accent = rowStyle?.invoke(row)
+ val isEven = index % 2 == 0
+ val _forceRedraw = state.contentRevision
+
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(IntrinsicSize.Min)
+ .background(
+ when {
+ selected -> MaterialTheme.colorScheme.secondaryContainer
+ accent?.background != null -> accent.background
+ isEven -> Color.Transparent
+ else -> MaterialTheme.colorScheme.surfaceVariant
+ }
+ )
+ .clickable {
+ state.select(row)
+ onRowClick?.invoke(row)
+ }
+ .heightIn(min = Density.ROW_MIN_HEIGHT),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ state.orderedColumns.forEach { column ->
+ val value = column.value(row)
+ val cell = column.accent?.invoke(row, value)
+
+ Box(
+ modifier = Modifier
+ .columnWidth(state, column, if (column.id == lastColumnId) slack else 0.dp)
+ .fillMaxHeight()
+ .background(cell?.background ?: Color.Transparent)
+ .padding(horizontal = 4.dp, vertical = 2.dp),
+ contentAlignment = Alignment.CenterStart,
+ ) {
+ TooltipArea(
+ tooltip = {
+ val hint = column.tooltip?.invoke(row, value) ?: value
+
+ if (hint.isNotBlank()) {
+ Surface(
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ tonalElevation = 2.dp,
+ ) {
+ Text(
+ hint,
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(4.dp),
+ )
+ }
+ }
+ },
+ ) {
+ Text(
+ value,
+ style = MaterialTheme.typography.bodyMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ color = cell?.foreground
+ ?: accent?.foreground
+ ?: MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .width(Density.COLUMN_HANDLE_WIDTH)
+ .fillMaxHeight(),
+ contentAlignment = Alignment.Center
+ ) {
+ Box(
+ modifier = Modifier
+ .width(Density.HAIRLINE)
+ .fillMaxHeight()
+ .background(MaterialTheme.colorScheme.outlineVariant)
+ )
+ }
+ }
+ }
+
+ HorizontalDivider(
+ thickness = Density.HAIRLINE,
+ color = MaterialTheme.colorScheme.outline
+ )
+ }
+ }
+ }
+
+ VerticalScrollbar(
+ adapter = rememberScrollbarAdapter(vertical),
+ modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
+ style = LocalScrollbarStyle.current.copy(
+ unhoverColor = MaterialTheme.colorScheme.outline,
+ hoverColor = MaterialTheme.colorScheme.onSurface,
+ ),
+ )
+ }
+
+ HorizontalScrollbar(
+ adapter = rememberScrollbarAdapter(horizontal),
+ modifier = Modifier.fillMaxWidth(),
+ style = LocalScrollbarStyle.current.copy(
+ unhoverColor = MaterialTheme.colorScheme.outline,
+ hoverColor = MaterialTheme.colorScheme.onSurface,
+ ),
+ )
+ }
+ }
+
+}
+
+
+/**
+ * A stored width when the operator has sized this column, otherwise the column's own
+ * weight. The stored value wins because it is the operator's decision.
+ *
+ * @param extra added to the last column so the table fills the window instead of
+ * leaving a band of empty background to the right. The JavaFX table did the
+ * same through `flexibleInitialWidth`.
+ */
+@Composable
+private fun Modifier.columnWidth(
+ state: DataTableState,
+ column: DataColumn,
+ extra: Dp = 0.dp,
+): Modifier {
+ val stored = state.widthOf(column.id)
+ val own = if (stored != null) stored.dp else DEFAULT_COLUMN_WIDTH * column.weight
+
+ return this.width(own + extra)
+}
+
+/**
+ * How much is left over once every column has its width. Zero when the columns are
+ * already wider than the window, in which case the table scrolls.
+ */
+@Composable
+private fun unusedWidth(state: DataTableState, available: Dp): Dp {
+ val used = state.orderedColumns.sumOf { column ->
+ val stored = state.widthOf(column.id)
+ val own = if (stored != null) stored else (DEFAULT_COLUMN_WIDTH * column.weight).value.toDouble()
+ own + Density.COLUMN_HANDLE_WIDTH.value
+ }
+
+ return (available.value - used).coerceAtLeast(0.0).dp
+}
+
+private val DEFAULT_COLUMN_WIDTH = 85.dp
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/Density.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/Density.kt
new file mode 100644
index 00000000..a29de120
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/Density.kt
@@ -0,0 +1,68 @@
+package kst4contest.view.compose
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.ui.unit.dp
+
+/**
+ * The measurements of the settings windows, in one place so the tabs cannot drift
+ * apart.
+ *
+ * Material 3 ships phone densities: a text field is 56dp tall, a button 40dp and
+ * fully rounded, a checkbox reserves a 48dp touch target. The Station tab alone has
+ * over thirty fields, so those defaults turn a settings window into a scrolling
+ * exercise and make a desktop application look like a phone.
+ *
+ * Every height here is a **minimum**, never a fixed size. The configured base font
+ * size scales all text through Typography.scaledTo, and a fixed height would clip a
+ * larger font instead of growing with it.
+ */
+internal object Density {
+
+ /** Text fields, pickers and anything else on the field column. */
+ val FIELD_MIN_HEIGHT = 30.dp
+
+ /** Inside a text field. The vertical value is what buys back the 56dp. */
+ val FIELD_CONTENT_PADDING = PaddingValues(horizontal = 8.dp, vertical = 2.dp)
+
+ val BUTTON_MIN_HEIGHT = 24.dp
+
+ val BUTTON_CONTENT_PADDING = PaddingValues(horizontal = 8.dp, vertical = 1.dp)
+
+ /** Square-ish, not a pill: the pill shape is the strongest mobile tell. */
+ val BUTTON_SHAPE = RoundedCornerShape(4.dp)
+
+ /** One row of a list or table. */
+ val ROW_MIN_HEIGHT = 26.dp
+
+ val TAB_MIN_HEIGHT = 30.dp
+
+ /**
+ * Left and right of a tab title. Material reserves 90dp minimum width per tab,
+ * which eleven tabs cannot live with; this is what a tab costs beyond its text.
+ */
+ val TAB_HORIZONTAL_PADDING = 10.dp
+
+ /** The box itself; the 48dp touch target around it is switched off separately. */
+ val CHECK_SIZE = 18.dp
+
+ /** The label column of the forms, wide enough for the longest label in use. */
+ val LABEL_COLUMN_WIDTH = 260.dp
+
+ /** Between a label and its field, and between two fields. */
+ val FIELD_GAP = 3.dp
+
+ /** Between buttons in a row. */
+ val BUTTON_GAP = 6.dp
+
+ /** Around a selected row, and the hairline between rows. */
+ val SELECTION_BORDER = 1.5.dp
+
+ /** Kept free at the right edge so the scrollbar never sits on a control. */
+ val SCROLLBAR_GUTTER = 12.dp
+
+ /** Grab area between two columns; the line drawn inside it is a hairline. */
+ val COLUMN_HANDLE_WIDTH = 5.dp
+
+ val HAIRLINE = 1.dp
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/DirectedMessageColumns.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/DirectedMessageColumns.kt
new file mode 100644
index 00000000..fc5b6007
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/DirectedMessageColumns.kt
@@ -0,0 +1,88 @@
+package kst4contest.view.compose
+
+import kst4contest.model.ChatMessage
+
+/**
+ * The columns of the directed-message table: what was said to this station.
+ *
+ * Its own pane above the send line rather than one of the tabs, because an operator must
+ * be able to see a private call without switching away from whatever they are reading.
+ *
+ * The ids are the keys the operator's stored column widths hang on, exactly as
+ * TableLayoutManager registered them under "private-messages".
+ *
+ * @param ownCallSign the local station, which gets no distance to itself
+ * @param formatMessage the controller's display formatting for a message text
+ */
+object DirectedMessageColumns {
+
+ fun all(
+ ownCallSign: () -> String? = { null },
+ formatMessage: (ChatMessage) -> String,
+ ): List> = listOf(
+ DataColumn(
+ id = "time",
+ title = "Time",
+ value = { MessageFormats.clockTime(it.messageGeneratedTime) },
+ weight = 0.7f,
+ ),
+ DataColumn(
+ id = "callsign",
+ title = "Callsign",
+ value = { MonitorColumns.callSignOf(it.sender) },
+ ),
+ DataColumn(id = "name", title = "Name", value = { it.sender?.name.orEmpty() }),
+ DataColumn(id = "qra", title = "QRA", value = { MonitorColumns.qraOf(it.sender) }),
+ DataColumn(
+ id = "qrb",
+ title = "QRB",
+ value = { message -> qrbOf(message, ownCallSign()) },
+ comparator = compareBy { it.sender?.qrb ?: 0.0 },
+ weight = 0.9f,
+ ),
+ DataColumn(
+ id = "message",
+ title = "Message",
+ value = formatMessage,
+ weight = 3f,
+ ),
+ DataColumn(
+ id = "last-qrg",
+ title = "Last known QRG",
+ value = { MessageFormats.paddedQrg(MonitorColumns.frequencyOf(it.sender)) },
+ weight = 0.9f,
+ ),
+ DataColumn(
+ id = "airscout",
+ title = "AP [minutes / pot%]",
+ value = { AirplaneCell.format(it.sender?.airPlaneReflectInfo) },
+ accent = { _, rendered -> AirplaneCell.accentFor(rendered) },
+ weight = 1.4f,
+ ),
+ DataColumn(
+ id = "category",
+ title = "Category",
+ value = { message ->
+ message.chatCategory
+ ?.let { it.getChatCategoryName(it.categoryNumber) }
+ .orEmpty()
+ },
+ weight = 1.2f,
+ ),
+ )
+
+ /**
+ * Distance and bearing as one cell, both cut to whole numbers to save width.
+ *
+ * The degree sign sits outside the bracket — "342 km (88)°" — which is how JavaFX
+ * wrote it and therefore how operators read it.
+ */
+ private fun qrbOf(message: ChatMessage, ownCallSign: String?): String {
+ val sender = message.sender ?: return ""
+ if (sender.callSign == null || sender.callSign == ownCallSign) return ""
+
+ val qrb = sender.qrb ?: return ""
+ val bearing = sender.getQTFdirection() ?: return ""
+ return "${qrb.toInt()} km (${bearing.toInt()})°"
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/DirectedMessageRowStyle.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/DirectedMessageRowStyle.kt
new file mode 100644
index 00000000..b24cf76c
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/DirectedMessageRowStyle.kt
@@ -0,0 +1,68 @@
+package kst4contest.view.compose
+
+import androidx.compose.ui.graphics.Color
+import kst4contest.view.PrivateMessageRowStyleResolver
+
+/**
+ * The row colouring of the directed-message table.
+ *
+ * A new private message is highlighted and the highlight fades over five minutes, so a call
+ * that arrived while the operator was looking at the station list is still findable — being
+ * noticed is what that pane exists for. Own messages are blue at any age: they are context,
+ * not news.
+ *
+ * The steps come from [PrivateMessageRowStyleResolver], which the JavaFX row factory uses, so
+ * the two cannot drift apart. The colours are the stylesheets' `.messageHighlight*-column`,
+ * identical in both designs.
+ */
+object DirectedMessageRowStyle {
+
+ /** `.messageHighlightOwn-column`. */
+ val OWN = Color(0xFF00FFFF)
+
+ /** `.messageHighlight30-column`: just arrived. */
+ val FRESH = Color(0xFF33CC33)
+
+ /**
+ * The fading green of `.messageHighlight30` through `.messageHighlight300`, in the order
+ * the resolver's age limits name them.
+ */
+ private val FADE = listOf(
+ FRESH,
+ Color(0xFF40BF40),
+ Color(0xFF4DB34D),
+ Color(0xFF59A659),
+ Color(0xFF669966),
+ Color(0xFF738C73),
+ )
+
+ /** The resolver's style class names, in the same order as [FADE]. */
+ private val FADE_CLASSES = listOf(
+ "messageHighlight30-column",
+ "messageHighlight60-column",
+ "messageHighlight90-column",
+ "messageHighlight120-column",
+ "messageHighlight180-column",
+ "messageHighlight300-column",
+ )
+
+ /**
+ * How a row is coloured, or null once the message is no longer news.
+ *
+ * The style class is asked of the resolver rather than recomputed here, so a change to
+ * the fade steps reaches both views at once.
+ */
+ fun accentFor(ownMessage: Boolean, ageSeconds: Long): CellAccent? {
+ val styleClass = PrivateMessageRowStyleResolver.resolveStyleClass(ownMessage, ageSeconds)
+ ?: return null
+
+ if (styleClass == PrivateMessageRowStyleResolver.OWN_STYLE_CLASS) {
+ return CellAccent(background = OWN, foreground = Color.Black)
+ }
+
+ val index = FADE_CLASSES.indexOf(styleClass)
+ if (index < 0) return null
+
+ return CellAccent(background = FADE[index], foreground = Color.White)
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/FormControls.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/FormControls.kt
new file mode 100644
index 00000000..11f78377
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/FormControls.kt
@@ -0,0 +1,564 @@
+package kst4contest.view.compose
+
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.FlowRowScope
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.interaction.collectIsHoveredAsState
+import androidx.compose.foundation.interaction.collectIsPressedAsState
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Checkbox
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.LocalMinimumInteractiveComponentSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextFieldDefaults
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.onKeyEvent
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+/**
+ * Form controls shared by the settings tabs.
+ *
+ * Numeric fields keep the operator's text while it is being typed and only hand a
+ * parsed value upwards when it parses. Rejecting keystrokes instead would make a
+ * field impossible to clear, and replacing an unparsable entry with a default would
+ * silently change a setting — PROJECT_CONTEXT is explicit that an absent or malformed
+ * value must not become a fabricated zero.
+ *
+ * Every control here is built to desktop density; see [Density] for why and for the
+ * measurements.
+ */
+object Form {
+
+ @Composable
+ fun text(label: String, value: String?, enabled: Boolean = true, onChange: (String) -> Unit) {
+ val draft = rememberDraft(value ?: "")
+
+ labelled(label) {
+ CompactTextField(
+ value = draft.value,
+ onValueChange = { typed ->
+ draft.value = typed
+ onChange(typed)
+ },
+ enabled = enabled,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+
+ @Composable
+ fun password(label: String, value: String?, enabled: Boolean = true, onChange: (String) -> Unit) {
+ val draft = rememberDraft(value ?: "")
+
+ labelled(label) {
+ CompactTextField(
+ value = draft.value,
+ onValueChange = { typed ->
+ draft.value = typed
+ onChange(typed)
+ },
+ enabled = enabled,
+ visualTransformation = PasswordVisualTransformation(),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+
+ /**
+ * A text field that is validated when it is committed, not while it is typed.
+ *
+ * This is the Compose counterpart of the JavaFX fields which hung their rule on
+ * `focusedProperty` — the DX cluster port, the spotter callsign, the beacon
+ * templates and the beacon interval. Committing on every keystroke would refuse
+ * every prefix of a valid entry, so the draft lives here until the operator leaves
+ * the field or presses Enter.
+ *
+ * @param stored the value currently stored; the draft falls back to it when an
+ * entry is refused, which is what those fields did with setText
+ * @param commit returns null when the value was accepted, otherwise the reason
+ * @param onRefused shown to the operator; the JavaFX fields opened an alert
+ */
+ @Composable
+ fun committed(
+ label: String,
+ stored: String,
+ commit: (String) -> String?,
+ onRefused: (String) -> Unit,
+ ) {
+ var draft by remember(stored) { mutableStateOf(stored) }
+
+ fun commitDraft() {
+ val refusal = commit(draft)
+ if (refusal != null) {
+ onRefused(refusal)
+ draft = stored
+ }
+ }
+
+ labelled(label) {
+ CompactTextField(
+ value = draft,
+ onValueChange = { draft = it },
+ modifier = Modifier
+ .fillMaxWidth()
+ .onFocusChanged { focus -> if (!focus.isFocused) commitDraft() }
+ .onKeyEvent { event ->
+ if (event.type == KeyEventType.KeyUp && event.key == Key.Enter) {
+ commitDraft()
+ true
+ } else {
+ false
+ }
+ },
+ )
+ }
+ }
+
+ /**
+ * Picks one item from a fixed list, the counterpart of the JavaFX ChoiceBox.
+ *
+ * A null selection is possible and shown as such: the second chat category has no
+ * value until the operator picks one, and inventing one would log the station into
+ * a chat it never chose.
+ */
+ @Composable
+ fun choice(
+ label: String,
+ items: List,
+ selected: T?,
+ describe: (T) -> String,
+ enabled: Boolean = true,
+ onSelect: (T) -> Unit,
+ ) {
+ var expanded by remember { mutableStateOf(false) }
+ val draft = rememberDraft(selected)
+
+ labelled(label) {
+ Box {
+ OutlinedButton(
+ onClick = { expanded = true },
+ enabled = enabled,
+ shape = Density.BUTTON_SHAPE,
+ contentPadding = Density.FIELD_CONTENT_PADDING,
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = Density.FIELD_MIN_HEIGHT),
+ ) {
+ Text(
+ draft.value?.let(describe) ?: "not selected",
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
+ items.forEach { item ->
+ DropdownMenuItem(
+ text = { Text(describe(item), style = MaterialTheme.typography.bodyMedium) },
+ onClick = {
+ expanded = false
+ draft.value = item
+ onSelect(item)
+ },
+ )
+ }
+ }
+ }
+ }
+ }
+
+ @Composable
+ fun inlineChoice(
+ label: String,
+ items: List,
+ selected: T?,
+ describe: (T) -> String,
+ enabled: Boolean = true,
+ onSelect: (T) -> Unit,
+ ) {
+ var expanded by remember { mutableStateOf(false) }
+ val draft = rememberDraft(selected)
+
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(label, style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(end = 8.dp))
+ Box {
+ OutlinedButton(
+ onClick = { expanded = true },
+ enabled = enabled,
+ shape = Density.BUTTON_SHAPE,
+ contentPadding = Density.FIELD_CONTENT_PADDING,
+ modifier = Modifier.heightIn(min = Density.FIELD_MIN_HEIGHT),
+ ) {
+ Text(
+ draft.value?.let(describe) ?: "not selected",
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+ DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
+ items.forEach { item ->
+ DropdownMenuItem(
+ text = { Text(describe(item), style = MaterialTheme.typography.bodyMedium) },
+ onClick = {
+ expanded = false
+ draft.value = item
+ onSelect(item)
+ },
+ )
+ }
+ }
+ }
+ }
+ }
+
+ @Composable
+ fun check(label: String, value: Boolean, onChange: (Boolean) -> Unit) {
+ val draft = rememberDraft(value)
+
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.heightIn(min = Density.ROW_MIN_HEIGHT),
+ ) {
+ CompactCheckbox(draft.value) { ticked ->
+ draft.value = ticked
+ onChange(ticked)
+ }
+ Text(label, modifier = Modifier.padding(start = 6.dp))
+ }
+ }
+
+ @Composable
+ fun int(label: String, value: Int, onChange: (Int) -> Unit) {
+ var draft by remember(value) { mutableStateOf(value.toString()) }
+
+ labelled(label) {
+ CompactTextField(
+ value = draft,
+ onValueChange = { typed ->
+ draft = typed
+ typed.trim().toIntOrNull()?.let(onChange)
+ },
+ isError = draft.trim().toIntOrNull() == null,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+
+ @Composable
+ fun decimal(label: String, value: Double, onChange: (Double) -> Unit) {
+ var draft by remember(value) { mutableStateOf(value.toString()) }
+
+ labelled(label) {
+ CompactTextField(
+ value = draft,
+ onValueChange = { typed ->
+ draft = typed
+ typed.trim().replace(',', '.').toDoubleOrNull()?.let(onChange)
+ },
+ isError = draft.trim().replace(',', '.').toDoubleOrNull() == null,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ }
+
+ /**
+ * A titled section.
+ *
+ * The JavaFX window drew `generateLabeledSeparator`: a rule, the label, another
+ * rule. Here the label leads and one rule runs to the right edge, which sits
+ * better above the left-aligned label column than a centred title does. Either
+ * way it is a visible division, which a bare line of text was not.
+ */
+ @Composable
+ fun section(title: String, content: @Composable () -> Unit) {
+ Column(
+ modifier = Modifier.fillMaxWidth().padding(top = 10.dp),
+ verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth().padding(bottom = 2.dp),
+ ) {
+ Text(title, style = MaterialTheme.typography.titleSmall)
+ HorizontalDivider(modifier = Modifier.weight(1f).padding(start = 8.dp))
+ }
+ content()
+ }
+ }
+
+ /**
+ * A section whose controls flow across the width instead of stacking.
+ *
+ * For short, like-for-like options — the nine band checkboxes, the startup filters.
+ * Stacked they cost nine rows of height for nine words; flowing they fill the width
+ * the window actually has and re-flow when it changes.
+ */
+ @OptIn(ExperimentalLayoutApi::class)
+ @Composable
+ fun flowingSection(title: String, content: @Composable FlowRowScope.() -> Unit) {
+ section(title) {
+ FlowRow(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ content = content,
+ )
+ }
+ }
+
+ /**
+ * A labelled row holding a control this object does not provide, so a tab can add
+ * one without losing the shared label column.
+ */
+ @Composable
+ fun labelledRow(label: String, field: @Composable () -> Unit) {
+ labelled(label, field)
+ }
+
+ /**
+ * The button of the settings windows: square-ish corners and desktop padding.
+ *
+ * Material's own Button is a 40dp pill, which is the single strongest reason the
+ * window looked like a phone application.
+ */
+ /**
+ * The button of the settings windows.
+ *
+ * Material's own Button is a 40dp pill; this one is square-ish and at desktop
+ * height. It also carries the two states the JavaFX stylesheets give every button:
+ * a gradient while the pointer is over it and a red border while it is held down.
+ */
+ @Composable
+ fun button(text: String, enabled: Boolean = true, onClick: () -> Unit) {
+ val palette = LocalJavaFxPalette.current
+ val interactions = remember { MutableInteractionSource() }
+ val hovered by interactions.collectIsHoveredAsState()
+ val pressed by interactions.collectIsPressedAsState()
+
+ val hoverGradient = palette.buttonHoverGradient
+
+ CompositionLocalProvider(
+ LocalMinimumInteractiveComponentSize provides 0.dp
+ ) {
+ Button(
+ onClick = onClick,
+ enabled = enabled,
+ shape = Density.BUTTON_SHAPE,
+ contentPadding = Density.BUTTON_CONTENT_PADDING,
+ interactionSource = interactions,
+ border = if (pressed) {
+ BorderStroke(Density.SELECTION_BORDER, palette.buttonPressedBorder)
+ } else {
+ null
+ },
+ colors = if (hovered && enabled && hoverGradient.isNotEmpty()) {
+ /*
+ * Compose paints a solid container, so the gradient is applied as the
+ * background below and the container itself is cleared. The text turns
+ * dark because the gradient the sheets use is a bright lime.
+ */
+ ButtonDefaults.buttonColors(
+ containerColor = Color.Transparent,
+ contentColor = Color.Black,
+ )
+ } else {
+ ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant,
+ contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ },
+ modifier = Modifier
+ .heightIn(min = Density.BUTTON_MIN_HEIGHT)
+ .then(
+ if (hovered && enabled && hoverGradient.size >= 2) {
+ Modifier.background(
+ brush = Brush.verticalGradient(hoverGradient),
+ shape = Density.BUTTON_SHAPE,
+ )
+ } else {
+ Modifier
+ }
+ ),
+ ) {
+ /*
+ * One line, always. A label left to wrap turns a squeezed button into a tall
+ * bar instead of simply being too narrow, and that bar then pushes the rest
+ * of the window out of view.
+ */
+ Text(text, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
+ }
+ }
+ }
+
+ /**
+ * Holds what the operator sees while they change it.
+ *
+ * The settings controls are bound to plain ChatPreferences values, and those are not
+ * Compose state: writing one triggers no recomposition, so a field bound straight to
+ * the stored value keeps painting the value from the last composition. Typing then
+ * writes through correctly and shows nothing, a checkbox never appears to tick, and a
+ * picker never appears to change — which is exactly how it behaved.
+ *
+ * Keyed on the stored value, so an external change still reaches the control, and so
+ * a setter that normalises its input (a trimmed space, an upper-cased callsign) puts
+ * the normalised value back. This is the pattern GuardedTextField already used for
+ * the external-interface fields.
+ */
+ @Composable
+ private fun rememberDraft(stored: T): MutableState =
+ remember(stored) { mutableStateOf(stored) }
+
+ @Composable
+ private fun labelled(label: String, field: @Composable () -> Unit) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.heightIn(min = Density.ROW_MIN_HEIGHT),
+ ) {
+ Text(
+ label,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.width(Density.LABEL_COLUMN_WIDTH),
+ )
+ field()
+ }
+ }
+}
+
+/**
+ * A text field at desktop density.
+ *
+ * Built from BasicTextField and the outlined decoration box rather than from
+ * OutlinedTextField, because that composable enforces a 56dp minimum height which
+ * cannot be passed a smaller one. The decoration box takes the content padding, and
+ * that is what buys the height back.
+ *
+ * The decoration box and its container are marked experimental in Material 3. The
+ * opt-in is deliberate and there is no alternative: it is the only supported way to
+ * build a text field below the enforced minimum height. Should the signature change,
+ * the two calls below are the whole surface to fix, and the tests around the tab states
+ * stay unaffected because none of them render.
+ *
+ * @param borderColor overrides the outline, so a caller can mark a selected row
+ * @param borderThickness overrides the outline width for the same reason
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun CompactTextField(
+ value: String,
+ onValueChange: (String) -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ isError: Boolean = false,
+ visualTransformation: VisualTransformation = VisualTransformation.None,
+ borderColor: Color? = null,
+ borderThickness: Dp? = null,
+) {
+ val interactionSource = remember { MutableInteractionSource() }
+ /*
+ * The container is the JavaFX -fx-control-inner-background, which both sheets set
+ * apart from the window background. Material leaves an outlined field transparent,
+ * which made every field vanish into the surface.
+ */
+ val colors = OutlinedTextFieldDefaults.colors(
+ unfocusedBorderColor = borderColor ?: MaterialTheme.colorScheme.outline,
+ focusedBorderColor = borderColor ?: MaterialTheme.colorScheme.primary,
+ unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
+ focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
+ disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
+ )
+
+ /*
+ * The colour has to be carried in the text style: BasicTextField has no theme of
+ * its own, so without this the text would stay black in the evening design.
+ */
+ val onField = MaterialTheme.colorScheme.surfaceVariant.let { field ->
+ val brightness = 0.3f * field.red + 0.59f * field.green + 0.11f * field.blue
+ if (brightness > 0.5f) Color.Black else Color.White
+ }
+ val textColor = if (enabled) onField else onField.copy(alpha = 0.38f)
+
+ BasicTextField(
+ value = value,
+ onValueChange = onValueChange,
+ enabled = enabled,
+ singleLine = true,
+ textStyle = MaterialTheme.typography.bodyMedium.copy(color = textColor),
+ cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
+ visualTransformation = visualTransformation,
+ interactionSource = interactionSource,
+ modifier = modifier.heightIn(min = Density.FIELD_MIN_HEIGHT),
+ ) { innerTextField ->
+ OutlinedTextFieldDefaults.DecorationBox(
+ value = value,
+ innerTextField = innerTextField,
+ enabled = enabled,
+ singleLine = true,
+ visualTransformation = visualTransformation,
+ interactionSource = interactionSource,
+ isError = isError,
+ colors = colors,
+ contentPadding = Density.FIELD_CONTENT_PADDING,
+ container = {
+ OutlinedTextFieldDefaults.Container(
+ enabled = enabled,
+ isError = isError,
+ interactionSource = interactionSource,
+ colors = colors,
+ shape = Density.BUTTON_SHAPE,
+ focusedBorderThickness = borderThickness ?: 2.dp,
+ unfocusedBorderThickness = borderThickness ?: 1.dp,
+ )
+ },
+ )
+ }
+}
+
+/**
+ * A checkbox without the 48dp touch target Material reserves around it. On a desktop
+ * form that target is dead space between every two rows.
+ */
+@Composable
+internal fun CompactCheckbox(checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
+ CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
+ Checkbox(
+ checked = checked,
+ onCheckedChange = onCheckedChange,
+ modifier = Modifier.size(Density.CHECK_SIZE),
+ )
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/JavaFxStylesheet.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/JavaFxStylesheet.kt
new file mode 100644
index 00000000..b504903e
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/JavaFxStylesheet.kt
@@ -0,0 +1,353 @@
+package kst4contest.view.compose
+
+import androidx.compose.ui.graphics.Color
+
+/**
+ * The colours the Compose windows take from the JavaFX stylesheets.
+ *
+ * @param fontSizePx the `-fx-font-size` of `.root`; both shipped sheets set 12px
+ */
+data class JavaFxPalette(
+ val base: Color,
+ /**
+ * What a window is actually painted with. Modena's `-fx-background` is
+ * `derive(-fx-base, 26.4%)`, not `-fx-base` itself: taking the base raw makes a
+ * Compose window visibly darker than the JavaFX one beside it.
+ */
+ val windowBackground: Color,
+ val accent: Color,
+ /**
+ * The colour the operator actually reads as the accent.
+ *
+ * `-fx-accent` only drives JavaFX's own selection bar, which barely shows. What is
+ * seen instead is `.text-field .text`: both sheets fill the text inside every field
+ * — and the table column headers of the evening sheet — with a green gradient. A
+ * solid colour is needed here, so the gradient is collapsed to its midpoint.
+ */
+ val textAccent: Color,
+ val controlInnerBackground: Color,
+ val labelTextFill: Color,
+ val separatorLine: Color,
+ val buttonHoverGradient: List,
+ val buttonPressedBorder: Color,
+ val fontSizePx: Float,
+)
+
+/**
+ * Reads the colours of a JavaFX stylesheet.
+ *
+ * This is deliberately not a CSS engine. It looks up the handful of declarations the
+ * Compose windows need and understands only what those declarations actually use:
+ * colour literals, named colours, references to another `-fx-` value in the same
+ * sheet, `derive(colour, percent)` and the stops of a `linear-gradient`.
+ *
+ * **The source is the classpath resource, the same one JavaFX loads.** The copies
+ * under the application directory are written at startup but never read back:
+ * `applyThemeStylesheet` passes the bare file name to `Scene.getStylesheets()`, and
+ * JavaFX resolves a relative stylesheet name against the classpath. Reading the copy
+ * here would make the two windows disagree exactly when an operator edits it.
+ *
+ * The daylight sheet declares almost nothing and relies on JavaFX's own Modena
+ * defaults, so every lookup falls back to the matching Modena value.
+ */
+object JavaFxStylesheet {
+
+ /** Modena's own `-fx-background: derive(-fx-base, 26.4%)`. */
+ private const val MODENA_BACKGROUND_DERIVE = 26.4
+
+ /* Modena's defaults, used wherever a sheet declares nothing. */
+ private val MODENA_BASE = Color(0xFFECECEC)
+ private val MODENA_ACCENT = Color(0xFF0096C9)
+ private val MODENA_CONTROL_INNER = Color(0xFFFFFFFF)
+ private val MODENA_TEXT = Color(0xFF000000)
+
+ private val NAMED_COLORS = mapOf(
+ "white" to Color.White,
+ "black" to Color.Black,
+ "lightgray" to Color(0xFFD3D3D3),
+ "lightgrey" to Color(0xFFD3D3D3),
+ "gray" to Color(0xFF808080),
+ "grey" to Color(0xFF808080),
+ "darkgray" to Color(0xFFA9A9A9),
+ "green" to Color(0xFF008000),
+ "lightgreen" to Color(0xFF90EE90),
+ "red" to Color(0xFFFF0000),
+ "blue" to Color(0xFF0000FF),
+ "yellow" to Color(0xFFFFFF00),
+ "orange" to Color(0xFFFFA500),
+ "transparent" to Color.Transparent,
+ )
+
+ /**
+ * @param resourcePath classpath path of the sheet, for example
+ * `/KST4ContestDefaultEvening.css`
+ */
+ fun read(resourcePath: String): JavaFxPalette {
+ val source = JavaFxStylesheet::class.java.getResource(resourcePath)?.readText().orEmpty()
+ val blocks = parseBlocks(source)
+ val root = blocks[".root"].orEmpty()
+
+ val base = colorOf(root["-fx-base"], root) ?: MODENA_BASE
+ val accent = colorOf(root["-fx-accent"], root) ?: MODENA_ACCENT
+
+ return JavaFxPalette(
+ base = base,
+ windowBackground = colorOf(root["-fx-background"], root)
+ ?: derive(base, MODENA_BACKGROUND_DERIVE),
+ textAccent = midpointOf(
+ gradientStops(blocks[".text-field .text"]?.get("-fx-fill"), root)
+ ) ?: accent,
+ accent = accent,
+ controlInnerBackground = colorOf(root["-fx-control-inner-background"], root)
+ ?: MODENA_CONTROL_INNER,
+ labelTextFill = colorOf(blocks[".label"]?.get("-fx-text-fill"), root) ?: MODENA_TEXT,
+ /* Modena draws the line one fifth darker than the base. */
+ separatorLine = colorOf(blocks[".separator *.line"]?.get("-fx-background-color"), root)
+ ?: derive(base, -20.0),
+ buttonHoverGradient = gradientStops(
+ blocks[".button:hover"]?.get("-fx-background-color"), root
+ ),
+ buttonPressedBorder = colorOf(blocks[".button:pressed"]?.get("-fx-border-color"), root)
+ ?: accent,
+ fontSizePx = root["-fx-font-size"]?.removeSuffix("px")?.trim()?.toFloatOrNull() ?: 12f,
+ )
+ }
+
+ /**
+ * JavaFX `derive(colour, percent)`.
+ *
+ * Delegates to `com.sun.javafx.util.Utils.deriveColor`, which is what the CSS
+ * function itself calls. JavaFX is in this process and on the classpath, so the
+ * two windows cannot disagree about a derived colour — and a reimplementation
+ * could, as an earlier attempt here proved: the brightness JavaFX derives from is
+ * not the HSB value but 0.3R + 0.59G + 0.11B, and the rest of the curve is not
+ * obvious either.
+ *
+ * Reflection rather than a direct call, because the package is internal and not
+ * something this module should compile against. If it is ever gone, the fallback
+ * moves the colour toward white or black by the given fraction, which is close
+ * enough that a window still opens with readable colours.
+ */
+ fun derive(color: Color, percent: Double): Color {
+ val baseBrightness = 0.3 * color.red + 0.59 * color.green + 0.11 * color.blue
+ var calcBrightness = percent / 100.0
+
+ if (calcBrightness > 0) {
+ if (baseBrightness > 0.85) {
+ calcBrightness *= 1.6
+ } else if (baseBrightness > 0.6) {
+ // no change
+ } else if (baseBrightness > 0.5) {
+ calcBrightness *= 0.9
+ } else if (baseBrightness > 0.4) {
+ calcBrightness *= 0.8
+ } else if (baseBrightness > 0.3) {
+ calcBrightness *= 0.7
+ } else {
+ calcBrightness *= 0.6
+ }
+ } else {
+ if (baseBrightness < 0.2) {
+ calcBrightness *= 0.6
+ }
+ }
+
+ calcBrightness = calcBrightness.coerceIn(-1.0, 1.0)
+
+ // RGBtoHSB
+ val r = color.red.toDouble()
+ val g = color.green.toDouble()
+ val b = color.blue.toDouble()
+ val cmax = maxOf(r, g, b)
+ val cmin = minOf(r, g, b)
+ val brightness = cmax
+ val saturation = if (cmax != 0.0) (cmax - cmin) / cmax else 0.0
+ var hue = 0.0
+
+ if (saturation != 0.0) {
+ val redc = (cmax - r) / (cmax - cmin)
+ val greenc = (cmax - g) / (cmax - cmin)
+ val bluec = (cmax - b) / (cmax - cmin)
+ hue = when {
+ r == cmax -> bluec - greenc
+ g == cmax -> 2.0 + redc - bluec
+ else -> 4.0 + greenc - redc
+ }
+ hue /= 6.0
+ if (hue < 0) {
+ hue += 1.0
+ }
+ }
+
+ hue *= 360.0
+ var derivedSat = saturation
+ var derivedBri = brightness
+
+ if (calcBrightness > 0) {
+ derivedSat *= (1.0 - calcBrightness)
+ derivedBri += (1.0 - derivedBri) * calcBrightness
+ } else {
+ derivedBri *= (calcBrightness + 1.0)
+ }
+
+ derivedSat = derivedSat.coerceIn(0.0, 1.0)
+ derivedBri = derivedBri.coerceIn(0.0, 1.0)
+
+ return Color.hsv(hue.toFloat(), derivedSat.toFloat(), derivedBri.toFloat(), color.alpha)
+ }
+
+ /** `selector { property: value; ... }`, comments removed. */
+ private fun parseBlocks(source: String): Map> {
+ val withoutComments = source.replace(Regex("/\\*.*?\\*/", RegexOption.DOT_MATCHES_ALL), "")
+ val blocks = mutableMapOf>()
+
+ Regex("([^{}]+)\\{([^{}]*)\\}").findAll(withoutComments).forEach { match ->
+ val selector = match.groupValues[1].trim().replace(Regex("\\s+"), " ")
+ val declarations = blocks.getOrPut(selector) { mutableMapOf() }
+
+ splitOnTopLevel(match.groupValues[2], ';').forEach { declaration ->
+ val separator = declaration.indexOf(':')
+ if (separator > 0) {
+ declarations[declaration.take(separator).trim()] =
+ declaration.substring(separator + 1).trim()
+ }
+ }
+ }
+
+ return blocks
+ }
+
+ /**
+ * Resolves one value to a colour, or null when it is absent or is something this
+ * reader does not need to understand.
+ *
+ * @param variables the `.root` declarations, where `-fx-` references are looked up
+ */
+ private fun colorOf(value: String?, variables: Map, depth: Int = 0): Color? {
+ val text = value?.trim()?.removeSuffix(";")?.trim() ?: return null
+
+ /* A malformed sheet must not spin here: -fx-a: -fx-b; -fx-b: -fx-a; */
+ if (depth > 8 || text.isEmpty()) {
+ return null
+ }
+
+ NAMED_COLORS[text.lowercase()]?.let { return it }
+
+ if (text.startsWith("#")) {
+ return hexColor(text)
+ }
+
+ if (text.startsWith("-fx-")) {
+ return colorOf(variables[text], variables, depth + 1)
+ }
+
+ if (text.startsWith("derive(")) {
+ val arguments = splitOnTopLevel(text.removePrefix("derive(").dropLast(1), ',')
+ if (arguments.size == 2) {
+ val source = colorOf(arguments[0], variables, depth + 1) ?: return null
+ val percent = arguments[1].removeSuffix("%").trim().toDoubleOrNull() ?: return null
+ return derive(source, percent)
+ }
+ return null
+ }
+
+ if (text.contains("linear-gradient(")) {
+ return gradientStops(text, variables).firstOrNull()
+ }
+
+ return null
+ }
+
+ /**
+ * The colour stops of the first `linear-gradient` in a value. A stop may carry a
+ * position (`green 0%`) or a direction (`from 0% 0% to 100% 200%`); only the
+ * colours are wanted here.
+ */
+ private fun gradientStops(value: String?, variables: Map): List {
+ val text = value?.trim() ?: return emptyList()
+ val start = text.indexOf("linear-gradient(")
+
+ if (start < 0) {
+ return emptyList()
+ }
+
+ var depth = 0
+ var end = -1
+
+ for (index in start + "linear-gradient".length until text.length) {
+ when (text[index]) {
+ '(' -> depth++
+ ')' -> {
+ depth--
+ if (depth == 0) {
+ end = index
+ break
+ }
+ }
+ }
+ }
+
+ if (end < 0) {
+ return emptyList()
+ }
+
+ return splitOnTopLevel(text.substring(start + "linear-gradient(".length, end), ',')
+ .filterNot { it.startsWith("from ") || it.startsWith("to ") }
+ .mapNotNull { colorOf(it.split(Regex("\\s+")).first(), variables) }
+ }
+
+ /**
+ * Collapses a gradient to one colour by averaging its ends. Compose paints solid
+ * where JavaFX paints a gradient across the glyphs, and the average is what the eye
+ * takes from the gradient anyway.
+ */
+ private fun midpointOf(stops: List): Color? {
+ if (stops.isEmpty()) {
+ return null
+ }
+
+ val first = stops.first()
+ val last = stops.last()
+
+ return Color(
+ red = (first.red + last.red) / 2f,
+ green = (first.green + last.green) / 2f,
+ blue = (first.blue + last.blue) / 2f,
+ alpha = 1f,
+ )
+ }
+
+ /** Splits on a separator that is not inside brackets. */
+ private fun splitOnTopLevel(text: String, separator: Char): List {
+ val parts = mutableListOf()
+ var depth = 0
+ var start = 0
+
+ text.forEachIndexed { index, character ->
+ when (character) {
+ '(' -> depth++
+ ')' -> depth--
+ separator -> if (depth == 0) {
+ parts.add(text.substring(start, index))
+ start = index + 1
+ }
+ }
+ }
+
+ parts.add(text.substring(start))
+ return parts.map { it.trim() }.filter { it.isNotEmpty() }
+ }
+
+ private fun hexColor(text: String): Color? {
+ val digits = text.removePrefix("#")
+
+ val expanded = when (digits.length) {
+ 3 -> digits.toCharArray().joinToString("") { "$it$it" }
+ 6 -> digits
+ else -> return null
+ }
+
+ val value = expanded.toLongOrNull(16) ?: return null
+ return Color(0xFF000000L or value)
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuBar.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuBar.kt
new file mode 100644
index 00000000..a2235431
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuBar.kt
@@ -0,0 +1,137 @@
+package kst4contest.view.compose
+
+import androidx.compose.runtime.Composable
+import kst4contest.controller.On4KstConnectionState
+import kst4contest.utils.PlatformUtils
+import androidx.compose.ui.window.FrameWindowScope
+import androidx.compose.ui.window.MenuBar
+
+/**
+ * What the menu bar does when something is picked.
+ *
+ * An interface rather than a dozen lambdas: every window is meant to carry this same bar, and a
+ * parameter list that long is misread sooner or later.
+ */
+interface MainMenuActions {
+ fun connect()
+ fun disconnect()
+ fun switchOperatorProfile()
+ fun exitApplication()
+ fun setQrgAsNameInChat()
+ fun toggleAwayState()
+ fun toggleSettingsWindow()
+ fun toggleMonitorWindow()
+ fun toggleStationMap()
+ fun useDarkDesign()
+ fun useDefaultDesign()
+ fun openDonationPage()
+ fun openHomepage()
+ fun openNewsgroup()
+ /** The JavaFX item and its handler are both commented out; kept so the bar can say so. */
+ fun openChangelog()
+
+ /** OV3T runs the aeroplane feed the AirScout predictions come from. */
+ fun openOv3tDonationPage()
+ fun contactAuthor()
+ fun showAbout()
+}
+
+/**
+ * The menu bar, declared once and called by every window.
+ *
+ * Compose has no shared bar across windows: `MenuBar` lives in the scope of a `Window`
+ * and macOS shows it in the system menu bar while that window is active. The JavaFX
+ * side arranged the same thing the other way round — `installSharedSystemMenuBar`
+ * mirrored the main window's menus into an invisible bar in every other window — and
+ * this is the Compose shape of it: one function each window can call.
+ *
+ * The labels of the two window toggles are asked for at the moment the bar is built,
+ * because a Compose window can be closed from its own frame without telling the menu.
+ */
+@Composable
+fun FrameWindowScope.Kst4ContestMenuBar(
+ state: MainMenuState,
+ actions: MainMenuActions,
+ settingsWindowOpen: Boolean,
+ monitorWindowOpen: Boolean,
+ /**
+ * The connection state, for the read-only macOS menu. On macOS the status bar hides its
+ * badge because JavaFX moved the indicator into the system menu bar, so without this
+ * menu a macOS operator would have no connection indicator at all.
+ */
+ connectionState: On4KstConnectionState? = null,
+ connectionDetail: String? = null,
+) {
+ MenuBar {
+ Menu("File") {
+ Item(state.connectLabel, enabled = state.canConnect, onClick = actions::connect)
+ Item("Disconnect", enabled = state.canDisconnect, onClick = actions::disconnect)
+ Item("Switch operator profile...", onClick = actions::switchOperatorProfile)
+ Item("Exit + disconnect", onClick = actions::exitApplication)
+ }
+
+ Menu("Options") {
+ Item(
+ "Set QRG as name in Chat (main category)",
+ enabled = state.canUseChatActions,
+ onClick = actions::setQrgAsNameInChat,
+ )
+ /*
+ * This label is the only place the AFK state is visible anywhere in the
+ * application, so it follows the state rather than being flipped inside the
+ * click handler as JavaFX did.
+ */
+ Item(
+ state.awayMenuLabel,
+ enabled = state.canUseChatActions,
+ onClick = actions::toggleAwayState,
+ )
+ Item(
+ if (settingsWindowOpen) "hide options" else "Show options",
+ onClick = actions::toggleSettingsWindow,
+ )
+ }
+
+ Menu("Windows") {
+ Item(
+ if (monitorWindowOpen) "Hide cluster / stranger QSOs"
+ else "Show cluster / stranger QSOs",
+ onClick = actions::toggleMonitorWindow,
+ )
+ Item(
+ if (settingsWindowOpen) "hide options" else "show options",
+ onClick = actions::toggleSettingsWindow,
+ )
+ Separator()
+ Item("Show / hide station map", onClick = actions::toggleStationMap)
+ Separator()
+ Item("Use dark mode design", onClick = actions::useDarkDesign)
+ Item("Use default mode design", onClick = actions::useDefaultDesign)
+ }
+
+ Menu("Info") {
+ Item("Donate for kst4Contest development via PayPal", onClick = actions::openDonationPage)
+ Item("Donate for OV3T´s plane feed service", onClick = actions::openOv3tDonationPage)
+ Item("Visit DARC X08-Homepage", onClick = actions::openHomepage)
+ Item("Join kst4Contest newsgroup", onClick = actions::openNewsgroup)
+ Item("Contact the author using default mail app", onClick = actions::contactAuthor)
+ Separator()
+ Item("About...", onClick = actions::showAbout)
+ }
+
+ /*
+ * The connection indicator, for macOS only: the system menu bar is where it lives
+ * there, because the status bar's badge is hidden on that platform. Read-only — the
+ * single item is disabled, exactly as menuItemConnectionStateDetailMacOs is.
+ */
+ if (PlatformUtils.isMacOs()) {
+ Menu(ConnectionIndicator.macOsMenuTitle(connectionState)) {
+ Item(
+ ConnectionIndicator.detailFor(connectionState, connectionDetail),
+ enabled = false,
+ onClick = {},
+ )
+ }
+ }
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuState.kt
new file mode 100644
index 00000000..1c468fa5
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainMenuState.kt
@@ -0,0 +1,67 @@
+package kst4contest.view.compose
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import kst4contest.controller.On4KstConnectionState
+
+/**
+ * Which menu items can be used.
+ *
+ * Compose state, not plain properties, and deliberately so: a menu item that merely
+ * looks usable is a worse place for a stale value than a form field. The operator
+ * presses it in the middle of a contest and nothing happens, or the wrong thing does.
+ * The JavaFX window drove the same flags from onConnectionStateChanged.
+ */
+class MainMenuState {
+
+ /** Before the first callback there is no session, which reads as disconnected. */
+ var connectionState: On4KstConnectionState by mutableStateOf(On4KstConnectionState.DISCONNECTED)
+
+ private val attemptRunning: Boolean
+ get() = connectionState.isConnectionAttemptActive
+
+ /**
+ * The Connect item's label, which names the chat the station will land in — and both when
+ * the second one is on, so the operator sees before pressing it what they are logging
+ * into. JavaFX built the same string in initMenuBar.
+ */
+ var connectLabel: String by mutableStateOf("Connect")
+
+ val canConnect: Boolean
+ get() = !attemptRunning
+
+ /** Also while an attempt is still running: it can be given up. */
+ val canDisconnect: Boolean
+ get() = attemptRunning
+
+ /**
+ * Sending to the chat — the away state, the QRG as name — needs a session that is
+ * really online. Logging in is not being in the chat, and a command sent then goes
+ * nowhere.
+ */
+ val canUseChatActions: Boolean
+ get() = connectionState.isOnline
+
+ /**
+ * Whether the operator is marked away in the chat.
+ *
+ * Compose state because the away item's own label is the ONLY place this is visible —
+ * there is no separate indicator anywhere. JavaFX flipped the label inside the click
+ * handler, which left it wrong whenever the state changed from elsewhere.
+ */
+ var awayFromChat: Boolean by mutableStateOf(false)
+
+ /**
+ * What the away item says. It names the next action rather than the current state,
+ * which is what the JavaFX wording did.
+ */
+ val awayMenuLabel: String
+ get() = if (awayFromChat) "Show me as ACTIVE in chat!" else "Show me as AWAY FROM chat!"
+
+ /** Never blocked: switching profiles tears the session down on its own. */
+ val canSwitchProfile: Boolean = true
+
+ /** Never blocked: an operator must always be able to leave. */
+ val canExit: Boolean = true
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindow.kt
new file mode 100644
index 00000000..97b5ce1e
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindow.kt
@@ -0,0 +1,253 @@
+package kst4contest.view.compose
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.key.onPreviewKeyEvent
+import androidx.compose.ui.window.FrameWindowScope
+import kst4contest.model.Band
+import kst4contest.model.ChatMember
+import kst4contest.model.ChatMessage
+import kst4contest.utils.PlatformUtils
+import java.util.function.Consumer
+
+/**
+ * The main window, assembled.
+ *
+ * The JavaFX original built this in 2164 lines inside start(). What is left here is the
+ * arrangement — every part was built and tested on its own in 5a, 5b and tasks 10 to 13d.
+ * That is the point of the split: this file says how the window is put together and
+ * nothing else, so a change to the arrangement cannot break a table.
+ *
+ * The window is a pure function of [MainWindowState]. That is what makes the profile
+ * switch a state swap instead of a restart.
+ */
+@Composable
+fun FrameWindowScope.MainWindow(
+ state: MainWindowState,
+ actions: MainMenuActions,
+ skedBands: List,
+ openInBrowser: Consumer,
+ onShowOnMap: () -> Unit,
+ onShowPathInAirScout: () -> Unit,
+ onStationSelected: (ChatMember) -> Unit,
+ /**
+ * A clicked private message prepares a reply to it. JavaFX resolved the receiver out of
+ * the message text when the sender was the local station, so replying to one's own
+ * message aims at whoever it was addressed to rather than at oneself.
+ */
+ onDirectedMessageClicked: (ChatMessage) -> Unit,
+ /** Whether a message came from this station; decides its row colour. */
+ isOwnMessage: (ChatMessage) -> Boolean,
+ /** How old a message is, for the fading highlight. */
+ messageAgeSeconds: (ChatMessage) -> Long,
+ onFiltersChanged: () -> Unit,
+ onShowMorePriorities: () -> Unit,
+ onCandidateClicked: (TimelineCandidate) -> Unit,
+) {
+ Kst4ContestMenuBar(
+ state = state.menu,
+ actions = actions,
+ settingsWindowOpen = state.surroundings.settingsWindowOpen,
+ monitorWindowOpen = state.surroundings.monitorWindowOpen,
+ connectionState = state.surroundings.connectionState,
+ connectionDetail = state.surroundings.connectionDetail,
+ )
+
+ /*
+ * The window-wide keys, where JavaFX had them: on the scene. Enter sends and Escape
+ * clears wherever the focus is, and Ctrl+1..Ctrl+0 inserts a snippet — on the field alone
+ * they would be gone the moment the operator clicked a station or a message row, which is
+ * most of the time.
+ */
+ Surface(
+ Modifier
+ .fillMaxSize()
+ .onPreviewKeyEvent { event ->
+ WindowShortcuts.handle(
+ event = event,
+ send = state.chatInput::send,
+ clear = state.chatInput::clear,
+ insertSnippet = { index ->
+ state.chatInput.insertSnippet(state.snippets.entries, index)
+ },
+ )
+ },
+ ) {
+ Column(Modifier.fillMaxSize()) {
+ StatusBar(
+ connectionState = state.surroundings.connectionState,
+ connectionDetail = state.surroundings.connectionDetail,
+ skedNotice = state.skedNotice,
+ bandUpgradeNotice = state.bandUpgradeNotice,
+ threadButtons = state.threadButtons,
+ showConnectionBadge = !PlatformUtils.isMacOs(),
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ HorizontalDivider(thickness = Density.HAIRLINE)
+
+ SplitterPane(
+ state = state.outerSplitter,
+ orientation = SplitterOrientation.HORIZONTAL,
+ modifier = Modifier.fillMaxSize(),
+ panes = listOf(
+ {
+ MessageColumn(
+ state = state,
+ onCandidateClicked = onCandidateClicked,
+ onDirectedMessageClicked = onDirectedMessageClicked,
+ isOwnMessage = isOwnMessage,
+ messageAgeSeconds = messageAgeSeconds,
+ )
+ },
+ {
+ StationColumn(
+ state = state,
+ skedBands = skedBands,
+ openInBrowser = openInBrowser,
+ onShowOnMap = onShowOnMap,
+ onShowPathInAirScout = onShowPathInAirScout,
+ onStationSelected = onStationSelected,
+ onFiltersChanged = onFiltersChanged,
+ onShowMorePriorities = onShowMorePriorities,
+ )
+ },
+ ),
+ )
+ }
+ }
+}
+
+/**
+ * The left column: what was said, and what this station says back.
+ *
+ * Five panes in the order the operator has looked at them for years — directed messages
+ * at the top, then the shortcut buttons, the timeline, the send line, and the public
+ * message tabs at the bottom.
+ */
+@Composable
+private fun MessageColumn(
+ state: MainWindowState,
+ onCandidateClicked: (TimelineCandidate) -> Unit,
+ onDirectedMessageClicked: (ChatMessage) -> Unit,
+ isOwnMessage: (ChatMessage) -> Boolean,
+ messageAgeSeconds: (ChatMessage) -> Long,
+) {
+ SplitterPane(
+ state = state.messageSplitter,
+ orientation = SplitterOrientation.VERTICAL,
+ modifier = Modifier.fillMaxSize(),
+ panes = listOf(
+ {
+ SnippetContextMenu(state.snippets.entries, state.chatInput) {
+ DataTableView(
+ state = state.directedMessages,
+ modifier = Modifier.fillMaxSize(),
+ rowStyle = { message ->
+ DirectedMessageRowStyle.accentFor(
+ ownMessage = isOwnMessage(message),
+ ageSeconds = messageAgeSeconds(message),
+ )
+ },
+ onRowClick = onDirectedMessageClicked,
+ )
+ }
+ },
+ { ShortcutButtonRow(state.shortcuts.entries, state.chatInput) },
+ {
+ TimelineView(
+ state = state.timeline,
+ onCandidateClicked = onCandidateClicked,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ },
+ {
+ ChatInputRow(
+ state = state.chatInput,
+ prefs = state.prefs,
+ ownQrgMain = state.ownQrgMain,
+ ownQrgSecond = state.ownQrgSecond,
+ antennaQtf = state.antennaQtf,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ },
+ {
+ SnippetContextMenu(state.snippets.entries, state.chatInput) {
+ MessageTabs(
+ publicMessages = state.publicMessages,
+ clusterMessages = state.clusterMessages,
+ qsoOfTheOther = state.qsoOfTheOther,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+ },
+ ),
+ )
+}
+
+/**
+ * The right column: who is on, who to work next, and everything about the one selected.
+ */
+@Composable
+private fun StationColumn(
+ state: MainWindowState,
+ skedBands: List,
+ openInBrowser: Consumer,
+ onShowOnMap: () -> Unit,
+ onShowPathInAirScout: () -> Unit,
+ onStationSelected: (ChatMember) -> Unit,
+ onFiltersChanged: () -> Unit,
+ onShowMorePriorities: () -> Unit,
+) {
+ SplitterPane(
+ state = state.rightSplitter,
+ orientation = SplitterOrientation.VERTICAL,
+ modifier = Modifier.fillMaxSize(),
+ panes = listOf(
+ {
+ Column(Modifier.fillMaxSize()) {
+ StationFilterBar(
+ filters = state.stationFilter,
+ activeBands = state.activeBands,
+ onChanged = {
+ onFiltersChanged()
+ },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ HorizontalDivider(thickness = Density.HAIRLINE)
+ SnippetContextMenu(state.snippets.entries, state.chatInput) {
+ DataTableView(
+ state = state.stations,
+ modifier = Modifier.fillMaxSize(),
+ onRowClick = onStationSelected,
+ )
+ }
+ }
+ },
+ {
+ TopPriorityBar(
+ state = state.topPriority,
+ onShowMore = onShowMorePriorities,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ },
+ {
+ SelectedStationPanel(
+ state = state.selectedStation,
+ messages = state.selectedStationMessages,
+ skedBands = skedBands,
+ activeBands = state.activeBands,
+ openInBrowser = openInBrowser,
+ onShowOnMap = onShowOnMap,
+ onShowPathInAirScout = onShowPathInAirScout,
+ modifier = Modifier.fillMaxSize(),
+ )
+ },
+ ),
+ )
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowFrame.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowFrame.kt
new file mode 100644
index 00000000..e7c80a5b
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowFrame.kt
@@ -0,0 +1,75 @@
+package kst4contest.view.compose
+
+import kst4contest.controller.On4KstConnectionState
+
+/** How big the main window opens. */
+data class MainWindowSize(val heightDp: Double, val widthDp: Double)
+
+/**
+ * The frame around the main window: the size it opens at and what its title says.
+ *
+ * Both are arithmetic and string building rather than drawing, which is why they live
+ * apart from the window itself and are covered by tests instead of by looking.
+ */
+object MainWindowFrame {
+
+ private const val FALLBACK_HEIGHT = 768.0
+ private const val FALLBACK_WIDTH = 1234.0
+
+ /**
+ * Room left for the native window decoration and the screen edge. The JavaFX scene
+ * size did not include the decoration, and Compose's window size does not include
+ * everything a tiling compositor puts around it either.
+ */
+ private const val SCREEN_MARGIN = 40.0
+
+ /**
+ * The remembered size, never larger than the screen it is opening on.
+ *
+ * The clamp is the point: a window remembered from a larger monitor opens with its
+ * buttons past the edge and no way to reach them. Each dimension falls back on its
+ * own, because half a stored size is still half a usable one.
+ */
+ fun startupSize(stored: DoubleArray?, screenHeight: Double, screenWidth: Double): MainWindowSize {
+ var height = FALLBACK_HEIGHT
+ var width = FALLBACK_WIDTH
+
+ if (stored != null && stored.size >= 2) {
+ if (stored[0].isFinite() && stored[0] > 0) height = stored[0]
+ if (stored[1].isFinite() && stored[1] > 0) width = stored[1]
+ }
+
+ val availableHeight = (screenHeight - SCREEN_MARGIN).coerceAtLeast(1.0)
+ val availableWidth = (screenWidth - SCREEN_MARGIN).coerceAtLeast(1.0)
+
+ return MainWindowSize(
+ heightDp = minOf(height, availableHeight),
+ widthDp = minOf(width, availableWidth),
+ )
+ }
+
+ /**
+ * The window title: what the chat is doing, and which profile, when it is not the
+ * only one. Two profiles open side by side have to be tellable apart from the
+ * taskbar.
+ */
+ fun title(chatState: String, profileName: String?): String =
+ if (profileName.isNullOrBlank()) chatState else "$chatState - $profileName"
+
+ /**
+ * What the title says while the chat is not usable.
+ *
+ * The wording separates a scheduled reconnect from a dead session, which is the
+ * difference between waiting and doing something about it.
+ */
+ fun offlineChatState(state: On4KstConnectionState?): String =
+ when (state ?: On4KstConnectionState.DISCONNECTED) {
+ On4KstConnectionState.RECONNECT_WAIT -> "CONNECTION LOST – reconnect scheduled"
+ On4KstConnectionState.CONNECTING -> "Connecting to ON4KST…"
+ On4KstConnectionState.WAITING_FOR_LOGIN_PROMPT,
+ On4KstConnectionState.AUTHENTICATING -> "Connected – authenticating with ON4KST…"
+ On4KstConnectionState.SYNCING_MAIN_CHAT,
+ On4KstConnectionState.SYNCING_SECOND_CHAT -> "Connected – synchronizing ON4KST chat data…"
+ else -> "DISCONNECTED!"
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowHost.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowHost.kt
new file mode 100644
index 00000000..ecce1f44
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowHost.kt
@@ -0,0 +1,112 @@
+package kst4contest.view.compose
+
+import kst4contest.model.Band
+import kst4contest.model.ChatMember
+import kst4contest.model.ChatMessage
+import java.util.function.Consumer
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.setValue
+
+/**
+ * Opens the Compose main window.
+ *
+ * Deliberately a sibling of the JavaFX window rather than its replacement for now: the two
+ * are meant to run side by side so the operator can compare them against a live session,
+ * which is the only way the differences of the last three Etappen came to light.
+ */
+object MainWindowHost {
+
+ private val host = ComposeWindowHost("main-window")
+
+ @JvmStatic
+ val isOpen: Boolean
+ get() = host.isOpen
+
+ @JvmStatic
+ fun applyDarkMode(darkMode: Boolean) = host.applyDarkMode(darkMode)
+
+ @JvmStatic
+ fun close() = host.close()
+
+ /**
+ * @param state everything the window draws; replacing it is what a profile switch will be
+ * @param title the window title, rebuilt by the caller as the chat state changes
+ */
+ private var currentState by androidx.compose.runtime.mutableStateOf(null)
+ private var currentActions by androidx.compose.runtime.mutableStateOf(null)
+ private var currentSkedBands by androidx.compose.runtime.mutableStateOf<(() -> List)?>(null)
+ private var currentOpenInBrowser by androidx.compose.runtime.mutableStateOf?>(null)
+ private var currentOnShowOnMap by androidx.compose.runtime.mutableStateOf<(() -> Unit)?>(null)
+ private var currentOnShowPathInAirScout by androidx.compose.runtime.mutableStateOf<(() -> Unit)?>(null)
+ private var currentOnStationSelected by androidx.compose.runtime.mutableStateOf<((ChatMember) -> Unit)?>(null)
+ private var currentOnDirectedMessageClicked by androidx.compose.runtime.mutableStateOf<((ChatMessage) -> Unit)?>(null)
+ private var currentIsOwnMessage by androidx.compose.runtime.mutableStateOf<((ChatMessage) -> Boolean)?>(null)
+ private var currentMessageAgeSeconds by androidx.compose.runtime.mutableStateOf<((ChatMessage) -> Long)?>(null)
+ private var currentOnFiltersChanged by androidx.compose.runtime.mutableStateOf<(() -> Unit)?>(null)
+ private var currentOnShowMorePriorities by androidx.compose.runtime.mutableStateOf<(() -> Unit)?>(null)
+ private var currentOnCandidateClicked by androidx.compose.runtime.mutableStateOf<((TimelineCandidate) -> Unit)?>(null)
+
+ @JvmStatic
+ fun show(
+ state: MainWindowState,
+ actions: MainMenuActions,
+ title: String,
+ darkMode: Boolean,
+ baseFontSizeSp: Float,
+ widthDp: Float,
+ heightDp: Float,
+ onResized: (Float, Float) -> Unit,
+ skedBands: () -> List,
+ openInBrowser: Consumer,
+ onShowOnMap: () -> Unit,
+ onShowPathInAirScout: () -> Unit,
+ onStationSelected: (ChatMember) -> Unit,
+ onDirectedMessageClicked: (ChatMessage) -> Unit,
+ isOwnMessage: (ChatMessage) -> Boolean,
+ messageAgeSeconds: (ChatMessage) -> Long,
+ onFiltersChanged: () -> Unit,
+ onShowMorePriorities: () -> Unit,
+ onCandidateClicked: (TimelineCandidate) -> Unit,
+ ) {
+ currentState = state
+ currentActions = actions
+ currentSkedBands = skedBands
+ currentOpenInBrowser = openInBrowser
+ currentOnShowOnMap = onShowOnMap
+ currentOnShowPathInAirScout = onShowPathInAirScout
+ currentOnStationSelected = onStationSelected
+ currentOnDirectedMessageClicked = onDirectedMessageClicked
+ currentIsOwnMessage = isOwnMessage
+ currentMessageAgeSeconds = messageAgeSeconds
+ currentOnFiltersChanged = onFiltersChanged
+ currentOnShowMorePriorities = onShowMorePriorities
+ currentOnCandidateClicked = onCandidateClicked
+
+ host.show(
+ title = title,
+ darkMode = darkMode,
+ baseFontSizeSp = baseFontSizeSp,
+ widthDp = widthDp,
+ heightDp = heightDp,
+ onResized = onResized,
+ ) { _ ->
+ val s = currentState ?: return@show
+ val a = currentActions ?: return@show
+ MainWindow(
+ state = s,
+ actions = a,
+ skedBands = currentSkedBands?.invoke() ?: emptyList(),
+ openInBrowser = currentOpenInBrowser ?: Consumer { _ -> },
+ onShowOnMap = currentOnShowOnMap ?: {},
+ onShowPathInAirScout = currentOnShowPathInAirScout ?: {},
+ onStationSelected = currentOnStationSelected ?: {},
+ onDirectedMessageClicked = currentOnDirectedMessageClicked ?: {},
+ isOwnMessage = currentIsOwnMessage ?: { false },
+ messageAgeSeconds = currentMessageAgeSeconds ?: { 0L },
+ onFiltersChanged = currentOnFiltersChanged ?: {},
+ onShowMorePriorities = currentOnShowMorePriorities ?: {},
+ onCandidateClicked = currentOnCandidateClicked ?: {},
+ )
+ }
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowState.kt
new file mode 100644
index 00000000..faa0a9cc
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowState.kt
@@ -0,0 +1,157 @@
+package kst4contest.view.compose
+
+import kst4contest.model.ChatMessage
+import kst4contest.model.ClusterMessage
+import kst4contest.model.ChatMember
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import kst4contest.controller.On4KstConnectionState
+import kst4contest.model.ChatPreferences
+import kst4contest.observe.SimpleRoster
+
+/**
+ * Everything the main window draws, in one object.
+ *
+ * This is the shape the profile switch needs. Today a switch tears the runtime down and
+ * builds a new Kst4ContestApplication, because the controls are inline-created instance
+ * fields and there is no other way to be rid of them. Once the window is a pure function
+ * of this object, switching profiles is replacing it — the reason the JavaFX version had
+ * to restart itself is gone.
+ *
+ * Nothing here holds a window or a thread; those belong to the runtime that builds this. It
+ * does hold three preference listeners, which is why [release] exists and has to be called
+ * when the state is dropped.
+ */
+class MainWindowState(
+ val activeBands: Set,
+ val prefs: ChatPreferences,
+
+ /** The station list with its filters: the operator's main working surface. */
+ val stations: DataTableState,
+ val stationFilter: StationFilterState,
+
+ /** Messages addressed to this station, in their own pane above the send line. */
+ val directedMessages: DataTableState,
+
+ /** The three tabs at the bottom. */
+ val publicMessages: DataTableState,
+ val clusterMessages: DataTableState,
+ val qsoOfTheOther: DataTableState,
+
+ val selectedStationMessages: DataTableState,
+ val selectedStation: SelectedStationState,
+ val topPriority: TopPriorityState,
+ val timeline: TimelineState,
+ val chatInput: ChatInputState,
+ val menu: MainMenuState,
+
+ /** The link and the other windows, pushed in because none of it is Compose state at source. */
+ val surroundings: MainWindowSurroundings,
+
+ /** The status indicators along the top. */
+ val skedNotice: BlinkingNotice,
+ val bandUpgradeNotice: BlinkingNotice,
+ val threadButtons: ThreadStatusButtons,
+
+ /** The three splitters, whose positions the operator has arranged and stored. */
+ val outerSplitter: SplitterState,
+ val messageSplitter: SplitterState,
+ val rightSplitter: SplitterState,
+) {
+
+ /** The shortcut buttons above the send line. */
+ val shortcuts = RosterMirror(prefs.getLst_txtShortCutBtnList())
+
+ /** The snippets behind Ctrl+1..Ctrl+0 and the right-click menus. */
+ val snippets = RosterMirror(prefs.getLst_txtSnipList())
+
+ /**
+ * The three small fields beside the send controls. Mirrored rather than read straight
+ * from the preferences, because all three move without the operator touching them: the
+ * rotator writes the heading, and the frequency follower writes the own QRG.
+ */
+ val ownQrgMain = ObservedValue(prefs.getMYQRGFirstCat())
+ val ownQrgSecond = ObservedValue(prefs.getMYQRGSecondCat())
+ val antennaQtf = ObservedValue(prefs.getActualQTF())
+
+ /**
+ * Stops following the preferences. Called when this state is dropped — a profile switch
+ * builds a new window over the same preferences, and a leaked follower keeps the old
+ * one alive and writing.
+ */
+ fun release() {
+ ownQrgMain.release()
+ ownQrgSecond.release()
+ antennaQtf.release()
+ }
+
+ companion object {
+
+ /** The five panes of the message column, in the order they are stacked. */
+ const val MESSAGE_PANE_COUNT = 5
+
+ /** Station list, top priorities, selected station. */
+ const val RIGHT_PANE_COUNT = 3
+ }
+}
+
+
+/**
+ * A roster from the preferences, mirrored into Compose state.
+ *
+ * SimpleRoster notifies its own listeners, none of which Compose can see, so a view drawn
+ * straight from one would render once and then go stale — the failure mode that produced
+ * most of this migration's invisible defects. The mirror is what the settings window's
+ * refresh hooks have been calling into thin air since Etappe 3b.
+ */
+class RosterMirror(private val roster: SimpleRoster?) {
+
+ private val live = mutableStateListOf().apply {
+ addAll(roster?.snapshot().orEmpty())
+ }
+
+ /** What a view reads; a read here is a subscription. */
+ val entries: List get() = live
+
+ /** Catches the mirror up with the roster. Called after every committed edit. */
+ fun refresh() {
+ val current = roster?.snapshot().orEmpty()
+ if (current == live.toList()) return
+ live.clear()
+ live.addAll(current)
+ }
+}
+
+
+/**
+ * What the window shows about its surroundings: the link, and which other windows are open.
+ *
+ * None of this is Compose state at its source — the connection state lives in the controller,
+ * the window flags in AtomicBooleans inside the hosts — so it is pushed in here instead of
+ * being read through a lambda at composition time. Read that way it would be drawn once: the
+ * badge frozen on whatever the link was when the window opened, and the menu's "hide options"
+ * saying the wrong thing for the rest of the session.
+ *
+ * @param menu kept in step with the connection state, because the badge and the menu are two
+ * renderings of one fact and an operator comparing them must not have to choose
+ */
+class MainWindowSurroundings(private val menu: MainMenuState = MainMenuState()) {
+
+ private val liveConnectionState =
+ mutableStateOf(On4KstConnectionState.DISCONNECTED)
+
+ var connectionState: On4KstConnectionState
+ get() = liveConnectionState.value
+ set(value) {
+ liveConnectionState.value = value
+ menu.connectionState = value
+ }
+
+ var connectionDetail: String by mutableStateOf("No ON4KST connection")
+
+ var settingsWindowOpen: Boolean by mutableStateOf(false)
+
+ var monitorWindowOpen: Boolean by mutableStateOf(false)
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MessageFormats.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MessageFormats.kt
new file mode 100644
index 00000000..3cd67b6b
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MessageFormats.kt
@@ -0,0 +1,52 @@
+package kst4contest.view.compose
+
+import kst4contest.controller.Utils4KST
+
+/**
+ * How the message tables show what arrives off the wire.
+ *
+ * Shared rather than repeated: the same value appears in the monitor window and in the
+ * tabs below the station list, and two tables of the same data disagreeing about how
+ * they show it is worse than either format on its own.
+ */
+internal object MessageFormats {
+
+ /**
+ * The epoch seconds off the wire as a clock time, the way the JavaFX cells showed
+ * them. An unreadable value is shown as it came: an operator seeing the raw text
+ * learns more than one seeing an empty cell.
+ */
+ fun clockTime(epochFromServer: String?): String {
+ val raw = epochFromServer?.trim().orEmpty()
+
+ if (raw.isEmpty()) {
+ return ""
+ }
+
+ return runCatching { Utils4KST().time_convertEpochToReadable(raw) }.getOrDefault(raw)
+ }
+
+ /**
+ * Three decimal places, as `formatQrgForUi` pads them in the main window. The same
+ * column appears in both windows, and two tables of the same data must not
+ * disagree about how they show it.
+ */
+ fun paddedQrg(raw: String): String {
+ val text = raw.trim().replace(',', '.')
+
+ if (text.isEmpty() || text.toDoubleOrNull() == null) {
+ return text
+ }
+
+ val dot = text.indexOf('.')
+
+ if (dot < 0) {
+ return text + ".000"
+ }
+
+ val decimals = text.length - dot - 1
+
+ return if (decimals >= 3) text else text + "0".repeat(3 - decimals)
+ }
+
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MessageTabs.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MessageTabs.kt
new file mode 100644
index 00000000..ec0008e0
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MessageTabs.kt
@@ -0,0 +1,90 @@
+package kst4contest.view.compose
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import kst4contest.model.ChatMessage
+import kst4contest.model.ClusterMessage
+
+/**
+ * The three tabs below the station list.
+ *
+ * Two of them are the monitor window's tables under their own layout ids — the same
+ * columns, separate stored widths, which is what PROJECT_CONTEXT requires. Only the
+ * public message tab is its own.
+ */
+@Composable
+fun MessageTabs(
+ publicMessages: DataTableState,
+ clusterMessages: DataTableState,
+ qsoOfTheOther: DataTableState,
+ modifier: Modifier = Modifier,
+) {
+ var selected by remember { mutableStateOf(0) }
+
+ val titles = listOf("Public messages", "DXCluster messages", "QSO of the other")
+
+ Column(modifier = modifier.fillMaxSize()) {
+ Row(modifier = Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surfaceVariant)) {
+ titles.forEachIndexed { index, title ->
+ val isSelected = index == selected
+ val underline = MaterialTheme.colorScheme.primary
+
+ Box(
+ modifier = Modifier
+ .heightIn(min = Density.TAB_MIN_HEIGHT)
+ .background(
+ if (isSelected) MaterialTheme.colorScheme.secondaryContainer
+ else Color.Transparent
+ )
+ .clickable { selected = index }
+ .drawBehind {
+ if (isSelected) {
+ val thickness = 2.dp.toPx()
+ drawRect(
+ color = underline,
+ topLeft = Offset(0f, size.height - thickness),
+ size = Size(size.width, thickness),
+ )
+ }
+ }
+ .padding(horizontal = Density.TAB_HORIZONTAL_PADDING, vertical = 5.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(title, style = MaterialTheme.typography.bodyMedium, maxLines = 1)
+ }
+ }
+ }
+
+ HorizontalDivider(thickness = Density.HAIRLINE)
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ when (selected) {
+ 0 -> DataTableView(publicMessages, modifier = Modifier.fillMaxSize())
+ 1 -> DataTableView(clusterMessages, modifier = Modifier.fillMaxSize())
+ else -> DataTableView(qsoOfTheOther, modifier = Modifier.fillMaxSize())
+ }
+ }
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MonitorColumns.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MonitorColumns.kt
new file mode 100644
index 00000000..643a9e2e
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MonitorColumns.kt
@@ -0,0 +1,72 @@
+package kst4contest.view.compose
+
+import kst4contest.controller.Utils4KST
+import kst4contest.model.ChatMember
+import kst4contest.model.ChatMessage
+import kst4contest.model.ClusterMessage
+
+/**
+ * The columns of the two monitor tables.
+ *
+ * The ids are not free to choose: they are part of the operator's stored layout, so
+ * renaming one loses their column widths without saying so. They are kept exactly as
+ * `TableLayoutManager.column(...)` registered them.
+ *
+ * Every value survives a half-filled message. These tables are fed straight from the
+ * wire, a sender or receiver can be missing, and a NullPointerException in a live
+ * table during a contest is the worst possible moment for one.
+ */
+object MonitorColumns {
+
+ fun dxCluster(): List> = listOf(
+ DataColumn(id = "time", title = "Time", value = { MessageFormats.clockTime(it.timeGenerated) }, weight = 0.7f),
+ DataColumn(id = "call-tx", title = "Call tx", value = { callSignOf(it.sender) }),
+ DataColumn(id = "locator-tx", title = "LOC tx", value = { qraOf(it.sender) }, weight = 0.7f),
+ DataColumn(id = "call-rx", title = "Call rx", value = { callSignOf(it.receiver) }),
+ DataColumn(id = "locator-rx", title = "LOC rx", value = { qraOf(it.receiver) }, weight = 0.7f),
+ DataColumn(id = "qrg", title = "QRG", value = { MessageFormats.paddedQrg(frequencyOf(it.receiver)) }, weight = 0.8f),
+ DataColumn(id = "message", title = "Message", value = { it.messageInhibited.orEmpty() }, weight = 2.5f),
+ /* "X" and not "true": the JavaFX cell printed a cross. */
+ DataColumn(id = "worked", title = "wkd", value = { if (it.isReceiverWkd) "X" else "" }, weight = 0.4f),
+ )
+
+ /**
+ * Nine columns, not ten. The JavaFX method built a "Name" column and never added
+ * it to the table; porting it would put a column on screen that release 1.49
+ * never showed.
+ */
+ fun qsoOfTheOther(): List> = listOf(
+ DataColumn(id = "time", title = "Time", value = { MessageFormats.clockTime(it.messageGeneratedTime) }, weight = 0.7f),
+ DataColumn(id = "call-tx", title = "Call TX", value = { callSignOf(it.sender) }),
+ DataColumn(id = "last-qrg-tx", title = "Last QRG TX", value = { frequencyOf(it.sender) }, weight = 0.8f),
+ DataColumn(id = "worked-tx", title = "wkd TX?", value = { workedMark(it.sender) }, weight = 0.4f),
+ DataColumn(id = "call-rx", title = "Call RX", value = { callSignOf(it.receiver) }),
+ DataColumn(id = "last-qrg-rx", title = "Last QRG RX", value = { frequencyOf(it.receiver) }, weight = 0.8f),
+ DataColumn(id = "worked-rx", title = "wkd RX?", value = { workedMark(it.receiver) }, weight = 0.4f),
+ DataColumn(id = "message", title = "Message", value = { it.messageText.orEmpty() }, weight = 2.5f),
+ /*
+ * From the sender, not from the message. Both carry a field of that name and
+ * they are set on different parsing paths, so they are not always the same
+ * value; the JavaFX cell read the sender's.
+ */
+ DataColumn(
+ id = "category",
+ title = "Category",
+ value = { message ->
+ message.sender?.chatCategory
+ ?.let { it.getChatCategoryName(it.categoryNumber) }
+ .orEmpty()
+ },
+ weight = 1.2f,
+ ),
+ )
+
+ internal fun callSignOf(member: ChatMember?): String = member?.callSign.orEmpty()
+
+ internal fun qraOf(member: ChatMember?): String = member?.qra.orEmpty()
+
+ internal fun frequencyOf(member: ChatMember?): String = member?.frequency?.get().orEmpty()
+
+ private fun workedMark(member: ChatMember?): String =
+ if (member != null && member.isWorked) "X" else ""
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MonitorWindow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MonitorWindow.kt
new file mode 100644
index 00000000..e1b394b9
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MonitorWindow.kt
@@ -0,0 +1,95 @@
+package kst4contest.view.compose
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import kst4contest.model.ChatMessage
+import kst4contest.model.ClusterMessage
+
+/**
+ * The cluster and QSO monitor.
+ *
+ * Two live tables, one above the other. This is the first window in which the table
+ * carries data that changes while the operator watches, which is why the row identity
+ * rules in DataTableState matter here and not in the settings window: in Etappe 2 the
+ * visible defects all came from that coupling, not from the table itself.
+ */
+object MonitorWindow {
+
+ private val host = ComposeWindowHost("monitor-window")
+
+ @JvmStatic
+ val isOpen: Boolean
+ get() = host.isOpen
+
+ @JvmStatic
+ fun applyDarkMode(darkMode: Boolean) = host.applyDarkMode(darkMode)
+
+ @JvmStatic
+ fun close() = host.close()
+
+ /**
+ * @param clusterTable fed from `getLst_clusterMemberList`
+ * @param qsoTable fed from `toOtherMessages` over the global chat message list
+ */
+ @JvmStatic
+ fun show(
+ clusterTable: DataTableState,
+ qsoTable: DataTableState,
+ darkMode: Boolean,
+ baseFontSizeSp: Float,
+ widthDp: Float,
+ heightDp: Float,
+ onResized: (Float, Float) -> Unit,
+ ) {
+ host.show(
+ title = "Cluster & QSO of the other",
+ darkMode = darkMode,
+ baseFontSizeSp = baseFontSizeSp,
+ widthDp = widthDp,
+ heightDp = heightDp,
+ onResized = onResized,
+ ) { _ ->
+ MonitorContent(clusterTable, qsoTable)
+ }
+ }
+}
+
+@Composable
+private fun MonitorContent(
+ clusterTable: DataTableState,
+ qsoTable: DataTableState,
+) {
+ Surface(modifier = Modifier.fillMaxSize()) {
+ Column(modifier = Modifier.fillMaxSize().padding(4.dp)) {
+ Text("DX cluster", style = MaterialTheme.typography.titleSmall)
+
+ /*
+ * Half each, fixed. The JavaFX window used a SplitPane whose divider the
+ * operator could drag, and that position was stored; reproducing the
+ * gesture belongs with the station list of Etappe 5, where a split pane is
+ * unavoidable. Until then an even split is honest rather than a broken
+ * handle.
+ */
+ DataTableView(clusterTable, modifier = Modifier.weight(1f).fillMaxWidth())
+
+ HorizontalDivider(thickness = Density.HAIRLINE)
+
+ Text(
+ "QSO of the other",
+ style = MaterialTheme.typography.titleSmall,
+ modifier = Modifier.padding(top = 4.dp),
+ )
+
+ DataTableView(qsoTable, modifier = Modifier.weight(1f).fillMaxWidth())
+ }
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/ObservedValue.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/ObservedValue.kt
new file mode 100644
index 00000000..8660d84b
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/ObservedValue.kt
@@ -0,0 +1,37 @@
+package kst4contest.view.compose
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import kst4contest.observe.ObservableValue
+import java.util.function.Consumer
+
+/**
+ * An [ObservableValue] from the domain layer, mirrored into Compose state.
+ *
+ * The domain's values notify their own listeners, none of which Compose can see, so a view
+ * drawn straight from one renders once and then silently goes stale. Three of them move on
+ * their own while the operator watches: the rotator writes the antenna heading, and the
+ * frequency follower writes the operator's own QRG.
+ *
+ * The JavaFX window did exactly this with attachQtfFollower() and attachOwnQrgFollower(),
+ * ending in a TextField; this one ends in Compose state.
+ *
+ * [release] must be called when the mirror is dropped. A profile switch builds a new window
+ * over the same preferences, and a leaked listener keeps the old one alive and writing.
+ */
+class ObservedValue(private val source: ObservableValue) {
+
+ var value: T? by mutableStateOf(source.get())
+ private set
+
+ private val follower = Consumer { updated -> value = updated }
+
+ init {
+ source.addListener(follower)
+ }
+
+ fun release() {
+ source.removeListener(follower)
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/OperatorProfilePickerWindow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/OperatorProfilePickerWindow.kt
new file mode 100644
index 00000000..ff43d8f4
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/OperatorProfilePickerWindow.kt
@@ -0,0 +1,205 @@
+package kst4contest.view.compose
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Window
+import androidx.compose.ui.window.WindowPosition
+import androidx.compose.ui.window.application
+import androidx.compose.ui.window.rememberWindowState
+import kst4contest.model.OperatorProfile
+import java.util.Optional
+import java.util.concurrent.CountDownLatch
+
+/**
+ * The text the JavaFX dialog showed below a profile name. Kept as a function so
+ * the wording and the rule behind it stay covered by a test: the root profile and
+ * a sharing profile both use the installation's shared worked database.
+ */
+internal fun workedDatabaseDescription(profile: OperatorProfile): String =
+ if (profile.isRootProfile || profile.isSharedWorkedDatabase) {
+ "shared station worked database"
+ } else {
+ "own worked database"
+ }
+
+/**
+ * Selection state of the operator profile picker, free of Compose so the rules
+ * are testable without a toolkit.
+ */
+class OperatorProfilePickerState(
+ val profiles: List,
+ preselectedProfileId: String?,
+) {
+ var selected: OperatorProfile? =
+ profiles.firstOrNull { it.profileId == preselectedProfileId } ?: profiles.firstOrNull()
+ private set
+
+ val canConfirm: Boolean
+ get() = selected != null
+
+ fun select(profile: OperatorProfile) {
+ if (profiles.any { it.profileId == profile.profileId }) {
+ selected = profiles.first { it.profileId == profile.profileId }
+ }
+ }
+}
+
+/**
+ * Asks the operator which profile to start with.
+ *
+ * Replaces the JavaFX dialog of the same name and keeps its contract: blocks
+ * until the operator decides, returns the chosen profile, or empty when they
+ * want to quit. Kst4ContestApplication uses this as a method reference, so the
+ * signature must not drift.
+ */
+object OperatorProfilePickerWindow {
+
+ /**
+ * The font size the JavaFX dialog hardcoded. It runs before any profile is
+ * loaded, so there is no configured size to read; using a different one here
+ * would make the dialog look unlike the windows that follow it.
+ */
+ private const val DIALOG_FONT_SIZE_SP = 12f
+
+ @JvmStatic
+ fun showAndSelect(
+ selectableProfiles: List,
+ preselectedProfileId: String?,
+ ): Optional {
+ val state = OperatorProfilePickerState(selectableProfiles, preselectedProfileId)
+ var chosen: OperatorProfile? = null
+ val closed = CountDownLatch(1)
+
+ /*
+ * application { } blocks until every Compose window closes, so it must not
+ * run on the JavaFX application thread. A dedicated thread plus a latch
+ * reproduces the blocking behaviour of Stage.showAndWait() that the callers
+ * expect. Measured in the coexistence probe: the JavaFX thread keeps
+ * running while this window is open.
+ */
+ Thread({
+ /*
+ * exitProcessOnExit = false is essential, not a preference: the default
+ * true makes application { } call exitProcess when the last Compose
+ * window closes. This dialog runs before the main window opens, so the
+ * default would end the application the moment the operator picks a
+ * profile.
+ */
+ application(exitProcessOnExit = false) {
+ Window(
+ onCloseRequest = {
+ chosen = null
+ exitApplication()
+ },
+ state = rememberWindowState(width = 380.dp, height = 300.dp,
+ position = WindowPosition(Alignment.Center)),
+ title = "Select operator profile",
+ ) {
+ Kst4ContestTheme(darkMode = false, baseFontSizeSp = DIALOG_FONT_SIZE_SP) {
+ PickerContent(
+ state = state,
+ onStart = { chosen = state.selected; exitApplication() },
+ onQuit = { chosen = null; exitApplication() },
+ )
+ }
+ }
+ }
+ closed.countDown()
+ }, "operator-profile-picker").start()
+
+ closed.await()
+ return Optional.ofNullable(chosen)
+ }
+}
+
+@Composable
+private fun PickerContent(
+ state: OperatorProfilePickerState,
+ onStart: () -> Unit,
+ onQuit: () -> Unit,
+) {
+ var selectedId by mutableStateOf(state.selected?.profileId)
+
+ Surface(modifier = Modifier.fillMaxSize()) {
+ Column(modifier = Modifier.padding(15.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("More than one operator profile is configured.")
+
+ LazyColumn(modifier = Modifier.weight(1f).fillMaxWidth()) {
+ items(state.profiles, key = { it.profileId }) { profile ->
+ val isSelected = profile.profileId == selectedId
+
+ Column {
+ /*
+ * Marked the same way as the profiles tab: a frame in the accent
+ * colour over a tinted row. Colouring only the name left the
+ * operator guessing which profile they were about to start.
+ */
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(
+ if (isSelected) {
+ MaterialTheme.colorScheme.secondaryContainer
+ } else {
+ Color.Transparent
+ }
+ )
+ .then(
+ if (isSelected) {
+ Modifier.border(
+ Density.SELECTION_BORDER,
+ MaterialTheme.colorScheme.primary,
+ )
+ } else {
+ Modifier
+ }
+ )
+ .selectable(selected = isSelected, onClick = {
+ state.select(profile)
+ selectedId = profile.profileId
+ })
+ .padding(horizontal = 6.dp, vertical = 4.dp),
+ ) {
+ Text(profile.displayName)
+ Text(
+ workedDatabaseDescription(profile),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+
+ if (!isSelected) {
+ HorizontalDivider(thickness = Density.HAIRLINE)
+ }
+ }
+ }
+ }
+
+ Row(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
+ Form.button("Start", enabled = state.canConfirm, onClick = onStart)
+ Form.button("Quit", onClick = onQuit)
+ }
+ }
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/PublicMessageColumns.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/PublicMessageColumns.kt
new file mode 100644
index 00000000..e1aedd8a
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/PublicMessageColumns.kt
@@ -0,0 +1,50 @@
+package kst4contest.view.compose
+
+import kst4contest.model.ChatMessage
+
+/**
+ * The columns of the public message tab.
+ *
+ * The other two tabs below the station list reuse the monitor tables under their own
+ * layout ids; this one is its own. Its ids are the operator's stored column widths,
+ * kept exactly as TableLayoutManager registered them.
+ */
+object PublicMessageColumns {
+
+ fun all(): List> = listOf(
+ DataColumn(
+ id = "time",
+ title = "Time",
+ value = { MessageFormats.clockTime(it.messageGeneratedTime) },
+ weight = 0.7f,
+ ),
+ DataColumn(
+ id = "callsign",
+ title = "Callsign",
+ value = { MonitorColumns.callSignOf(it.sender) },
+ ),
+ DataColumn(id = "name", title = "Name", value = { it.sender?.name.orEmpty() }),
+ DataColumn(
+ id = "last-qrg",
+ title = "Last QRG",
+ value = { MessageFormats.paddedQrg(MonitorColumns.frequencyOf(it.sender)) },
+ weight = 0.8f,
+ ),
+ DataColumn(
+ id = "message",
+ title = "Message",
+ value = { it.messageText.orEmpty() },
+ weight = 3f,
+ ),
+ DataColumn(
+ id = "category",
+ title = "Category",
+ value = { message ->
+ message.sender?.chatCategory
+ ?.let { it.getChatCategoryName(it.categoryNumber) }
+ .orEmpty()
+ },
+ weight = 1.2f,
+ ),
+ )
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationMessageColumns.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationMessageColumns.kt
new file mode 100644
index 00000000..15a38e10
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationMessageColumns.kt
@@ -0,0 +1,60 @@
+package kst4contest.view.compose
+
+import kst4contest.model.ChatMessage
+
+/**
+ * The columns of the selected station message tab.
+ */
+object SelectedStationMessageColumns {
+
+ fun all(): List> = listOf(
+ DataColumn(
+ id = "time",
+ title = "Time",
+ value = { MessageFormats.clockTime(it.messageGeneratedTime) },
+ weight = 0.7f,
+ ),
+ DataColumn(
+ id = "call-tx",
+ title = "Sender",
+ value = { MonitorColumns.callSignOf(it.sender) },
+ weight = 0.8f,
+ ),
+ DataColumn(
+ id = "call-rx",
+ title = "Receiver",
+ value = { MonitorColumns.callSignOf(it.receiver) },
+ weight = 0.8f,
+ ),
+ DataColumn(
+ id = "qrg-tx",
+ title = "Last QRG TX",
+ value = { it.sender?.frequency?.get()?.takeIf { s -> s.isNotBlank() } ?: "" },
+ weight = 0.8f,
+ ),
+ DataColumn(
+ id = "qrg-rx",
+ title = "Last QRG RX",
+ value = { it.receiver?.frequency?.get()?.takeIf { s -> s.isNotBlank() } ?: "" },
+ weight = 0.8f,
+ ),
+ DataColumn(
+ id = "message",
+ title = "Message",
+ value = { it.messageText.orEmpty() },
+ weight = 3f,
+ ),
+ DataColumn(
+ id = "wkd-rx",
+ title = "wkd RX?",
+ value = { if (it.receiver?.isWorked == true) "wkd" else "" },
+ weight = 0.6f,
+ ),
+ DataColumn(
+ id = "wkd-tx",
+ title = "wkd TX?",
+ value = { if (it.sender?.isWorked == true) "wkd" else "" },
+ weight = 0.6f,
+ ),
+ )
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationPanel.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationPanel.kt
new file mode 100644
index 00000000..7cb7f72a
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationPanel.kt
@@ -0,0 +1,252 @@
+package kst4contest.view.compose
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.foundation.selection.selectable
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.LocalMinimumInteractiveComponentSize
+import androidx.compose.material3.RadioButton
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.foundation.layout.size
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import kst4contest.model.Band
+import java.util.function.Consumer
+
+/**
+ * The panel below the station list, for whichever station is selected.
+ *
+ * Its rules live in [SelectedStationState] and are tested there; what is here is the
+ * drawing. With nothing selected it says so rather than showing dead controls — the
+ * JavaFX pane disabled them, which looks the same as broken.
+ *
+ * @param openInBrowser opens a lookup address; reaching the browser is the host
+ * application's business
+ */
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+fun SelectedStationPanel(
+ state: SelectedStationState,
+ messages: DataTableState,
+ /** The bands this station is set up for; BandOpportunityResolver decides them. */
+ skedBands: List,
+ /** My own station's enabled bands; used for the "Tag not QRV" checkboxes. */
+ activeBands: Set,
+ openInBrowser: Consumer,
+ onShowOnMap: () -> Unit,
+ onShowPathInAirScout: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val member = state.selected
+
+ Column(
+ modifier = modifier.fillMaxWidth().padding(4.dp),
+ verticalArrangement = Arrangement.spacedBy(3.dp),
+ ) {
+ if (member == null) {
+ Text(
+ "No station selected.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.outline,
+ )
+ return@Column
+ }
+
+ Text(
+ member.callSign.orEmpty() + " " + member.qra.orEmpty(),
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Bold,
+ )
+
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ modifier = Modifier.padding(bottom = 8.dp)
+ ) {
+ val category = member.chatCategory?.let { it.getChatCategoryName(it.categoryNumber) }
+ if (category != null) {
+ Text(category, style = MaterialTheme.typography.bodySmall)
+ }
+ if (member.getQTFdirection() != null) {
+ Text("QTF: ${member.getQTFdirection()}°", style = MaterialTheme.typography.bodySmall)
+ }
+ if (member.qrb != null) {
+ Text("QRB: ${member.qrb} km", style = MaterialTheme.typography.bodySmall)
+ }
+
+ val lastActSec = member.activityTimeLastInEpoch
+ if (lastActSec > 0L) {
+ val actStr = MessageFormats.clockTime(lastActSec.toString())
+ val minAgo = kst4contest.controller.Utils4KST.time_getSecondsBetweenEpochAndNow(lastActSec.toString()) / 60
+ Text("Last activity: $actStr ($minAgo min ago)", style = MaterialTheme.typography.bodySmall)
+ }
+
+ val detectedBands = state.detectedBands()
+ if (detectedBands.isNotBlank()) {
+ Text(detectedBands, style = MaterialTheme.typography.bodySmall)
+ }
+ }
+
+ Form.section("Show messages") {
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
+ SelectedMessageFilter.entries.forEach { filter ->
+ RadioChoice(
+ label = filter.label,
+ selected = state.messageFilter == filter,
+ onSelect = { state.messageFilter = filter },
+ )
+ }
+ }
+ }
+
+ DataTableView(
+ state = messages,
+ modifier = Modifier.weight(1f).fillMaxWidth(),
+ )
+
+ Form.section("Tag not QRV") {
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
+ val activeNotQrvBands = NotQrvBand.entries.filter { activeBands.contains(it.band) }
+
+ activeNotQrvBands.forEach { band ->
+ Form.check(band.label, state.isTaggedNotQrv(band)) {
+ state.tagNotQrv(band, it)
+ }
+ }
+
+ Form.check("all", activeNotQrvBands.all { state.isTaggedNotQrv(it) }) {
+ state.tagNotQrvAll(it)
+ }
+ }
+ }
+
+ Form.section("Sked") {
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP)
+ ) {
+ Form.inlineChoice(
+ label = "Sked in (min)",
+ items = SelectedStationState.SKED_MINUTES,
+ selected = state.skedMinutes,
+ describe = { it.toString() },
+ onSelect = { state.skedMinutes = it },
+ )
+ Form.inlineChoice(
+ label = "Band",
+ items = skedBands,
+ selected = state.skedBand,
+ describe = { it.prefix + " MHz" },
+ onSelect = { state.skedBand = it },
+ )
+ Form.inlineChoice(
+ label = "Mode",
+ items = SelectedStationState.SKED_MODES,
+ selected = state.skedMode,
+ describe = { it },
+ onSelect = { state.skedMode = it },
+ )
+ }
+
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) {
+ Form.check("Remind-PM in", state.remindPm) { state.remindPm = it }
+ Form.inlineChoice(
+ label = "Reminder offsets",
+ items = SelectedStationState.REMINDER_OFFSETS,
+ selected = state.reminderOffsets,
+ describe = { it },
+ enabled = state.remindPm,
+ onSelect = { state.reminderOffsets = it },
+ )
+ }
+
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
+ Form.button("Create sked", enabled = state.canCreateSked) { state.createSked() }
+ Form.button("Sked fail", enabled = state.canCreateSked) { state.markSkedFail() }
+ Form.button("Reset fail", enabled = state.canCreateSked) { state.resetSkedFail() }
+ }
+ }
+
+ Form.section("Look up and show") {
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
+ Form.button("Show path in AS", onClick = onShowPathInAirScout)
+ Form.button("🗺 Show on map", onClick = onShowOnMap)
+ Form.button("Lookup on qrz.com", enabled = state.canLookUp) {
+ openInBrowser.accept(state.qrzComUrl())
+ }
+ Form.button("Lookup on qrzcq.com", enabled = state.canLookUp) {
+ openInBrowser.accept(state.qrzCqUrl())
+ }
+ }
+ }
+
+
+ }
+}
+
+/**
+ * The two priority buttons beside the station list: a shortcut to the station the
+ * score says to work next.
+ */
+@Composable
+fun TopPriorityBar(
+ state: TopPriorityState,
+ onShowMore: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier.padding(2.dp),
+ horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text("Priority:", style = MaterialTheme.typography.bodySmall)
+
+ state.entries.forEach { entry ->
+ Form.button(entry.label, onClick = entry.select)
+ }
+
+ Form.button("more", onClick = onShowMore)
+ }
+}
+
+
+/**
+ * One of a set of mutually exclusive choices.
+ *
+ * Material's RadioButton carries the same 48dp minimum that made every field too tall on
+ * the desktop, so the touch target is released the way the other compact controls do it.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun RadioChoice(label: String, selected: Boolean, onSelect: () -> Unit) {
+ CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
+ Row(
+ modifier = Modifier
+ .selectable(selected = selected, onClick = onSelect)
+ .padding(end = Density.BUTTON_GAP),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ RadioButton(
+ selected = selected,
+ onClick = onSelect,
+ modifier = Modifier.size(Density.CHECK_SIZE),
+ )
+ Text(
+ text = label,
+ modifier = Modifier.padding(start = 4.dp),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationState.kt
new file mode 100644
index 00000000..eb912bdc
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationState.kt
@@ -0,0 +1,183 @@
+package kst4contest.view.compose
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import kst4contest.model.Band
+import kst4contest.model.ChatMember
+
+/**
+ * The bands a station can be tagged as not being on, under the labels the panel shows.
+ *
+ * @param read whether the station is QRV on this band
+ * @param write sets that flag
+ */
+enum class NotQrvBand(
+ val label: String,
+ val band: kst4contest.model.Band,
+ val read: (ChatMember) -> Boolean,
+ val write: (ChatMember, Boolean) -> Unit,
+) {
+ B_50("50", kst4contest.model.Band.B_50, { it.isQrv50 }, { m, qrv -> m.isQrv50 = qrv }),
+ B_70("70", kst4contest.model.Band.B_70, { it.isQrv70 }, { m, qrv -> m.isQrv70 = qrv }),
+ B_144("144", kst4contest.model.Band.B_144, { it.isQrv144 }, { m, qrv -> m.isQrv144 = qrv }),
+ B_432("432", kst4contest.model.Band.B_432, { it.isQrv432 }, { m, qrv -> m.isQrv432 = qrv }),
+ B_23("23cm", kst4contest.model.Band.B_1296, { it.isQrv1240 }, { m, qrv -> m.isQrv1240 = qrv }),
+ B_13("13cm", kst4contest.model.Band.B_2320, { it.isQrv2300 }, { m, qrv -> m.isQrv2300 = qrv }),
+ B_9("9cm", kst4contest.model.Band.B_3400, { it.isQrv3400 }, { m, qrv -> m.isQrv3400 = qrv }),
+ B_6("6cm", kst4contest.model.Band.B_5760, { it.isQrv5600 }, { m, qrv -> m.isQrv5600 = qrv }),
+ B_3("3cm", kst4contest.model.Band.B_10G, { it.isQrv10G }, { m, qrv -> m.isQrv10G = qrv }),
+}
+
+/**
+ * Which messages the panel lists for the selected station.
+ *
+ * One choice, not four switches: the JavaFX controls were a toggle group, so two can
+ * never be on at once.
+ */
+enum class SelectedMessageFilter(val label: String) {
+ PM_TO_ME("pm to me"),
+ PM_TO_OTHER("pm to other"),
+ PUBLIC("public msgs"),
+ NOTHING("nothing"),
+}
+
+/**
+ * The panel below the station list, for whichever station is selected.
+ *
+ * Its "tag not qrv" marks are not display state: they are written into the station and
+ * propagated to the active members, so a wrong one hides that station from every list
+ * filtering on QRV. That is why they go through the controller rather than being kept
+ * here.
+ *
+ * @param propagateNotQrv hands the changed station to the controller, which is what
+ * `propagateNotQrvStateToActiveMembers` does
+ * @param createSked takes the station, the minutes, the band and the mode; returns
+ * whether it was accepted
+ */
+class SelectedStationState(
+ private val propagateNotQrv: (ChatMember) -> Unit,
+ private val createSked: (ChatMember, Int, Band?, String?) -> Boolean,
+ private val markSkedFail: (ChatMember) -> Unit = { },
+ private val resetSkedFail: (ChatMember) -> Unit = { },
+ private val onMessageFilterChange: (SelectedMessageFilter?) -> Unit = { },
+ private val detectBands: (ChatMember) -> String = { "" },
+ private val resolveDefaultSkedBand: (ChatMember) -> Band? = { null },
+) {
+
+ var selected: ChatMember? by mutableStateOf(null)
+ private set
+
+ /** Bumped on a tag so the panel redraws; the flags live on the station itself. */
+ private var tagRevision: Int by mutableStateOf(0)
+
+ var skedMinutes: Int by mutableStateOf(DEFAULT_SKED_MINUTES)
+
+ var skedBand: Band? by mutableStateOf(null)
+
+ var skedMode: String? by mutableStateOf(null)
+
+ private var _messageFilter by mutableStateOf(null)
+
+ /**
+ * Which messages the panel follows, or null until the operator picks one.
+ *
+ * JavaFX has the setSelected(true) on "pm to me" commented out, so its toggle group
+ * starts with nothing on. Starting on a filter would silently hide messages nobody
+ * asked to hide.
+ */
+ var messageFilter: SelectedMessageFilter?
+ get() = _messageFilter
+ set(value) {
+ _messageFilter = value
+ onMessageFilterChange(value)
+ }
+
+ var remindPm: Boolean by mutableStateOf(false)
+
+ var reminderOffsets: String by mutableStateOf(REMINDER_OFFSETS.first())
+
+ val canCreateSked: Boolean
+ get() = selected != null
+
+ val canLookUp: Boolean
+ get() = !selected?.callSign.isNullOrBlank()
+
+ fun select(member: ChatMember?) {
+ if (selected?.callSignRaw != member?.callSignRaw) {
+ skedBand = member?.let { resolveDefaultSkedBand(it) }
+ }
+ selected = member
+ }
+
+ fun isTaggedNotQrv(band: NotQrvBand): Boolean {
+ @Suppress("UNUSED_EXPRESSION") tagRevision
+
+ val member = selected ?: return false
+
+ return !band.read(member)
+ }
+
+ /** Tagged means not QRV, so the station's QRV flag goes the other way. */
+ fun tagNotQrv(band: NotQrvBand, tagged: Boolean) {
+ val member = selected ?: return
+
+ band.write(member, !tagged)
+ tagRevision++
+ propagateNotQrv(member)
+ }
+
+ fun tagNotQrvAll(tagged: Boolean) {
+ val member = selected ?: return
+
+ NotQrvBand.entries.forEach { it.write(member, !tagged) }
+ tagRevision++
+ propagateNotQrv(member)
+ }
+
+ /** @return whether a sked was created; false when nothing is selected. */
+ fun createSked(): Boolean {
+ val member = selected ?: return false
+
+ return createSked(member, skedMinutes, skedBand, skedMode)
+ }
+
+ /**
+ * Marks the path to this station as failed.
+ *
+ * Permanent until reset, and it lowers the priority score sharply — so it is an
+ * answer to "I called and heard nothing", not to "not now".
+ */
+ fun markSkedFail() {
+ selected?.let(markSkedFail)
+ }
+
+ fun resetSkedFail() {
+ selected?.let(resetSkedFail)
+ }
+
+ fun detectedBands(): String {
+ val member = selected ?: return ""
+ val ignored = tagRevision
+ return detectBands(member)
+ }
+
+
+ fun qrzComUrl(): String = QRZ_COM + selected?.callSign.orEmpty()
+
+ fun qrzCqUrl(): String = QRZ_CQ + selected?.callSign.orEmpty()
+
+ companion object {
+ /** The minutes the JavaFX choice offered, and the one it started on. */
+ val SKED_MINUTES = listOf(2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 20)
+
+ val SKED_MODES = listOf("SSB", "CW")
+
+ /** How long before a sked the reminders come, in minutes. */
+ val REMINDER_OFFSETS = listOf("2+1", "5+2+1", "10+5+2+1")
+
+ private const val DEFAULT_SKED_MINUTES = 5
+ private const val QRZ_COM = "https://www.qrz.com/db/"
+ private const val QRZ_CQ = "https://www.qrzcq.com/call/"
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsTabs.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsTabs.kt
new file mode 100644
index 00000000..84d8037e
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsTabs.kt
@@ -0,0 +1,287 @@
+package kst4contest.view.compose
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import kst4contest.controller.ChatController
+import kst4contest.controller.ActiveOperatorProfile
+import kst4contest.controller.OperatorProfileManagementService
+import kst4contest.model.ChatMember
+import kst4contest.model.OperatorProfile
+import kst4contest.view.compose.tabs.AirscoutTab
+import kst4contest.view.compose.tabs.AirscoutTabState
+import kst4contest.view.compose.tabs.BeaconTab
+import kst4contest.view.compose.tabs.BeaconTabState
+import kst4contest.view.compose.tabs.GuiOptionsTab
+import kst4contest.view.compose.tabs.GuiOptionsTabState
+import kst4contest.view.compose.tabs.LogSynchTab
+import kst4contest.view.compose.tabs.LogSynchTabState
+import kst4contest.view.compose.tabs.MessageHandlingTab
+import kst4contest.view.compose.tabs.MessageHandlingTabState
+import kst4contest.view.compose.tabs.NotificationTab
+import kst4contest.view.compose.tabs.NotificationTabState
+import kst4contest.view.compose.tabs.ProfilesTab
+import kst4contest.view.compose.tabs.ProfilesTabState
+import kst4contest.view.compose.tabs.ShortcutsTab
+import kst4contest.view.compose.tabs.ShortcutsTabState
+import kst4contest.view.compose.tabs.StationTab
+import kst4contest.view.compose.tabs.StationTabState
+import kst4contest.view.compose.tabs.TrxSynchTab
+import kst4contest.view.compose.tabs.TrxSynchTabState
+import kst4contest.view.compose.tabs.WorkedDatabaseTab
+import kst4contest.view.compose.tabs.WorkedDatabaseTabState
+
+/**
+ * The work the settings window needs from the main window.
+ *
+ * These are the callbacks whose implementation lives in the JavaFX window and cannot
+ * be reached from a ChatController: rebuilding controls, binding the own-QRG
+ * follower and refreshing a table. Everything else the tabs need comes from the
+ * controller, which is why it is not listed here.
+ */
+interface SettingsHost {
+
+ /**
+ * Rebuilds the button row above the message field from the shortcut list.
+ *
+ * The suffix keeps this distinct from the window's own private method of the same
+ * purpose, which touches JavaFX directly; the implementation of this one has to hop
+ * onto the JavaFX thread first, because the settings window runs on its own.
+ */
+ fun refreshShortcutButtonsFromSettings()
+
+ /** Rebuilds the Ctrl+1..Ctrl+0 context menus from the snippet list. */
+ fun refreshTextSnippetContextMenusFromSettings()
+
+ /** The detected subnet broadcast address for Win-Test, or null. */
+ fun detectWintestBroadcastAddress(): String?
+
+ /** Attaches or detaches the follower that mirrors the own QRG into the station field. */
+ fun applyOwnQrgFollower(enabled: Boolean)
+
+ /** Re-reads the worked-stations table for display. */
+ fun refreshWorkedStationsView()
+
+ /**
+ * Switches the application to another operator profile. The tab must not do this
+ * itself: the switch reloads preferences and the worked database, which only the
+ * application can sequence.
+ */
+ fun switchToOperatorProfile(profile: OperatorProfile)
+
+ /**
+ * Starts the ON4KST session — the work of the Connect button.
+ *
+ * @return null when the session was started, otherwise the reason it was not
+ */
+ fun connectFromSettings(): String?
+
+ /** Drops the session and keeps the client running. */
+ fun disconnectOnlyFromSettings()
+
+ /** Drops the session and closes the client. */
+ fun disconnectAndCloseChatFromSettings()
+
+ /**
+ * Persists the preferences.
+ *
+ * @return the file the settings went to, or null when it could not be written
+ */
+ fun savePreferencesFromSettings(): String?
+}
+
+/**
+ * Builds the settings tabs in the order the JavaFX TabPane had them.
+ *
+ * The wiring lives here rather than in the Java window on purpose: every call that
+ * reaches past ChatPreferences into the running session is made in one readable
+ * place, and the foreign-write coverage test can see it. A tab that only wrote a
+ * preference and skipped its session call would look correct in the file and do
+ * nothing until the next restart.
+ */
+fun buildSettingsTabs(
+ controller: ChatController,
+ host: SettingsHost,
+ notices: SettingsNotices,
+): List {
+ val prefs = controller.chatPreferences
+
+ val stationState = StationTabState(
+ prefs,
+ applyMainCategory = { category -> controller.setChatCategoryMain(category) },
+ applySecondCategory = { category -> controller.setChatCategorySecondChat(category) },
+ )
+
+ val notificationState = NotificationTabState(
+ prefs,
+ controller.lstNotify_QSOSniffer_sniffedCallSignList,
+ applyDxClusterServerEnabled = { enabled ->
+ /*
+ * Starting a server that nothing can reach yet is pointless, so the JavaFX
+ * checkbox only touched the server while the session was logged in; the
+ * flag alone decides what happens at the next login.
+ */
+ if (controller.isConnectedAndLoggedIn) {
+ if (enabled) controller.startDxClusterServerIfEnabled() else controller.stopDxClusterServer()
+ }
+ },
+ applyDxClusterServerPort = {
+ if (controller.isConnectedAndLoggedIn && prefs.isNotify_dxClusterServerEnabled()) {
+ controller.restartDxClusterServerIfEnabled()
+ }
+ },
+ broadcastTestSpot = { spot -> deliverTestSpot(controller, spot) },
+ )
+
+ val shortcutsState = ShortcutsTabState(
+ prefs,
+ refreshShortcutButtons = host::refreshShortcutButtonsFromSettings,
+ refreshTextSnippetContextMenus = host::refreshTextSnippetContextMenusFromSettings,
+ )
+
+ val beaconState = BeaconTabState(
+ prefs,
+ validateBeaconTemplate = { template -> controller.validateBeaconTemplate(template) },
+ restartBeaconTimer = { controller.restartBeaconTimer() },
+ mainCategoryName = categoryName(controller.chatCategoryMain, "Main chat category"),
+ secondCategoryName = categoryName(controller.chatCategorySecondChat, "Second chat category"),
+ )
+
+ val logSynchState = LogSynchTabState(
+ prefs,
+ restartWintestListener = { controller.restartWintestUdpListenerIfEnabled() },
+ stopWintestListener = { controller.stopWintestUdpListener() },
+ detectWintestBroadcastAddress = host::detectWintestBroadcastAddress,
+ )
+
+ val workedDatabaseState = WorkedDatabaseTabState(
+ resetInDatabase = { controller.dbHandler.resetWorkedDataInDB() },
+ refreshGuiLists = {
+ controller.resetWorkedAndQrvInfoInGuiLists()
+ controller.refreshWorkedStateAndDatabaseListFromDatabase()
+ },
+ refreshView = host::refreshWorkedStationsView,
+ workedRoster = controller.lst_DBBasedWkdCallSignList,
+ )
+
+ val profilesState = ProfilesTabState(
+ OperatorProfileManagementService(),
+ activeSelection = { ActiveOperatorProfile.get() },
+ requestActivation = host::switchToOperatorProfile,
+ )
+
+ val reportRefusal: (String) -> Unit = notices::problem
+
+ /*
+ * Order and titles are those of the JavaFX TabPane, character for character. The
+ * comment there is explicit that operators navigate these tabs by muscle memory,
+ * so a tidier order or a shorter title would be a regression, not an improvement.
+ */
+ return listOf(
+ SettingsTab("Station") { StationTab(stationState) },
+ SettingsTab("Log synch") { LogSynchTab(logSynchState) },
+ SettingsTab("TRX synch") { TrxSynchTab(TrxSynchTabState(prefs, host::applyOwnQrgFollower)) },
+ SettingsTab("Airscout") { AirscoutTab(AirscoutTabState(prefs)) },
+ SettingsTab("Notification") { NotificationTab(notificationState, reportRefusal) },
+ SettingsTab("Shortcuts") { ShortcutsTab(shortcutsState, reportRefusal) },
+ SettingsTab("Beacon") { BeaconTab(beaconState, reportRefusal) },
+ SettingsTab("Messagehandling") { MessageHandlingTab(MessageHandlingTabState(prefs)) },
+ /*
+ * Selecting this tab re-read the database in the JavaFX window; the list is a
+ * snapshot and would otherwise show what was true when the window opened.
+ */
+ SettingsTab(
+ "Workedstn database",
+ onSelected = { controller.refreshWorkedStateAndDatabaseListFromDatabase() },
+ ) { WorkedDatabaseTab(workedDatabaseState) },
+ SettingsTab("GUI") { GuiOptionsTab(GuiOptionsTabState(prefs)) },
+ /*
+ * Appended last on purpose so no established tab position shifts, the reason
+ * the JavaFX tab gave for the same placement.
+ */
+ SettingsTab("Profiles", onSelected = profilesState::refresh) { ProfilesTab(profilesState) },
+ )
+}
+
+/**
+ * Delivers the test spot and reports why it could not be delivered, in the order the
+ * JavaFX button checked: session first, then a connected logger, then the delivery
+ * itself. The messages are the ones the operator knows.
+ */
+private fun deliverTestSpot(controller: ChatController, spot: ChatMember): String? {
+ if (!controller.isConnectedAndLoggedIn || !controller.chatPreferences.isNotify_dxClusterServerEnabled()) {
+ return "Connect KST4Contest and enable the local DX Cluster server first."
+ }
+
+ val server = controller.dxClusterServer
+ ?: return "Connect KST4Contest and enable the local DX Cluster server first."
+
+ if (!server.hasConnectedClients()) {
+ return "No DX Cluster client is connected to KST4Contest."
+ }
+
+ if (!server.broadcastSingleDXClusterEntryToLoggers(spot)) {
+ return "The test spot could not be delivered."
+ }
+
+ return null
+}
+
+/** The label a chat category carries in the beacon tab, or a fallback when unset. */
+private fun categoryName(category: kst4contest.model.ChatCategory?, fallback: String): String =
+ category?.getChatCategoryName(category.categoryNumber) ?: fallback
+
+/**
+ * The bottom button row of the settings window.
+ *
+ * Built here because it needs the controller for its state and the host for its
+ * actions, and because it belongs with the tabs it sits under. The `close` action comes
+ * from the window itself: only the window can end its own Compose application.
+ */
+fun settingsButtons(
+ controller: ChatController,
+ host: SettingsHost,
+ notices: SettingsNotices,
+): @Composable (close: () -> Unit) -> Unit {
+ val barState = ConnectionBarState(controller.chatPreferences, controller)
+
+ return { close ->
+ /*
+ * The bar is rebuilt after every action so its labels and enabled states follow
+ * the session. The JavaFX bar was told by onConnectionStateChanged instead.
+ */
+ var revision by remember { mutableStateOf(0) }
+
+ @Suppress("UNUSED_EXPRESSION") revision
+
+ ConnectionBar(
+ state = barState,
+ onConnect = {
+ val refusal = host.connectFromSettings()
+ if (refusal != null) notices.problem(refusal)
+ revision++
+ },
+ onSave = {
+ val target = host.savePreferencesFromSettings()
+ if (target == null) {
+ notices.problem(
+ "The configuration file could not be written. The settings stay " +
+ "active for this session."
+ )
+ } else {
+ notices.info(
+ "Settings are stored as default to the xml config file:\n" + target
+ )
+ }
+ },
+ onApplyAndClose = close,
+ onDisconnectAndCloseChat = host::disconnectAndCloseChatFromSettings,
+ onDisconnectOnly = {
+ host.disconnectOnlyFromSettings()
+ revision++
+ },
+ )
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsWindow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsWindow.kt
new file mode 100644
index 00000000..de7bcc8b
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsWindow.kt
@@ -0,0 +1,302 @@
+package kst4contest.view.compose
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.ui.graphics.Color
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.foundation.LocalScrollbarStyle
+import androidx.compose.foundation.VerticalScrollbar
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.rememberScrollbarAdapter
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+/**
+ * One tab of the settings window: a title and the content to show.
+ *
+ * Tabs are handed in rather than listed here so each one can be written and tested on
+ * its own, and so the window does not need to know what any of them contains.
+ */
+class SettingsTab(
+ val title: String,
+ /**
+ * Called when the operator selects this tab, before its content is shown. The
+ * worked-stations tab used it to re-read the database, which it has to: its list is
+ * a snapshot taken when the window was built.
+ */
+ val onSelected: (() -> Unit)? = null,
+ val content: @Composable () -> Unit,
+)
+
+/** Something the window has to tell the operator. */
+sealed interface SettingsNotice {
+
+ val message: String
+
+ /** It happened; this is what the operator should know about it. */
+ data class Info(override val message: String) : SettingsNotice
+
+ /** It did not happen, and this is why. */
+ data class Problem(override val message: String) : SettingsNotice
+}
+
+/**
+ * Carries a notice from a tab or a button to the one dialog that shows it.
+ *
+ * The JavaFX window opened an Alert per field and per button. One reporter keeps that
+ * behaviour without every tab having to carry a dialog, and because the window opens
+ * at most once there is at most one of these in flight.
+ */
+class SettingsNotices {
+
+ var current: SettingsNotice? by mutableStateOf(null)
+ private set
+
+ fun info(message: String) {
+ current = SettingsNotice.Info(message)
+ }
+
+ fun problem(message: String) {
+ current = SettingsNotice.Problem(message)
+ }
+
+ fun dismiss() {
+ current = null
+ }
+}
+
+/**
+ * The settings window.
+ *
+ * Unlike the operator profile picker this window does **not** block: the operator keeps
+ * working while it is open, exactly as the JavaFX window behaved. It is also opened at
+ * most once — a second instance would let two windows write the same preferences
+ * against each other.
+ *
+ * Saving does not collect values. Every control writes into ChatPreferences as it
+ * changes; the button bar only persists, which is what the JavaFX "Save settings"
+ * button did.
+ */
+object SettingsWindow {
+
+ /**
+ * Opening, closing, the single instance and the design switch all live in
+ * ComposeWindowHost; this object adds only what is specific to the settings
+ * window. The shared part was written here first and is now on its third window,
+ * and its close path is exactly where the profile switch broke.
+ */
+ private val host = ComposeWindowHost("settings-window")
+
+ /** Whether the window is currently open, so a toggle can tell. */
+ @JvmStatic
+ val isOpen: Boolean
+ get() = host.isOpen
+
+ /** Follows a design switch made in the main window. */
+ @JvmStatic
+ fun applyDarkMode(darkMode: Boolean) = host.applyDarkMode(darkMode)
+
+ /** Closes the window and waits for it to be gone; see ComposeWindowHost.close. */
+ @JvmStatic
+ fun close() = host.close()
+
+ /**
+ * @param buttons the bottom button row; it receives the action that closes the
+ * window, because only the window itself can end its Compose application
+ */
+ @JvmStatic
+ fun show(
+ tabs: List,
+ notices: SettingsNotices,
+ darkMode: Boolean,
+ baseFontSizeSp: Float,
+ widthDp: Float,
+ heightDp: Float,
+ onResized: (Float, Float) -> Unit,
+ buttons: @Composable (close: () -> Unit) -> Unit,
+ ) {
+ host.show(
+ title = "Change Client Settings",
+ darkMode = darkMode,
+ baseFontSizeSp = baseFontSizeSp,
+ widthDp = widthDp,
+ heightDp = heightDp,
+ onResized = onResized,
+ ) { close ->
+ SettingsContent(tabs, notices, buttons, close)
+ }
+ }
+}
+
+@Composable
+private fun SettingsContent(
+ tabs: List,
+ notices: SettingsNotices,
+ buttons: @Composable (close: () -> Unit) -> Unit,
+ close: () -> Unit,
+) {
+ var selected by remember { mutableStateOf(0) }
+
+ Surface(modifier = Modifier.fillMaxSize()) {
+ Column(
+ modifier = Modifier.padding(10.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ SettingsTabStrip(
+ tabs = tabs,
+ selectedIndex = selected,
+ onSelect = { index ->
+ selected = index
+ tabs[index].onSelected?.invoke()
+ },
+ )
+
+ /*
+ * One scroll region for the whole window, with a scrollbar beside it. The
+ * tabs used to scroll each for themselves, and none of them could show a
+ * bar: a tab does not know the window it sits in, so content simply ended
+ * mid-form with nothing to say that more was below.
+ */
+ val scroll = rememberScrollState()
+
+ Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .verticalScroll(scroll)
+ .padding(end = Density.SCROLLBAR_GUTTER),
+ ) {
+ tabs.getOrNull(selected)?.content?.invoke()
+ }
+
+ VerticalScrollbar(
+ adapter = rememberScrollbarAdapter(scroll),
+ modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
+ style = LocalScrollbarStyle.current.copy(
+ /* Muted on purpose: a bar in the accent colour shouts. */
+ unhoverColor = MaterialTheme.colorScheme.outline,
+ hoverColor = MaterialTheme.colorScheme.onSurface,
+ ),
+ )
+ }
+
+ buttons(close)
+ }
+ }
+
+ notices.current?.let { notice ->
+ AlertDialog(
+ onDismissRequest = notices::dismiss,
+ title = {
+ Text(
+ when (notice) {
+ is SettingsNotice.Info -> "Info"
+ is SettingsNotice.Problem -> "Settings"
+ }
+ )
+ },
+ text = { Text(notice.message) },
+ confirmButton = { TextButton(onClick = notices::dismiss) { Text("OK") } },
+ )
+ }
+}
+
+/**
+ * The row of tab buttons.
+ *
+ * Material's ScrollableTabRow is not usable here: it gives every tab a minimum width
+ * of 90dp and a height of 48dp, both private constants. Eleven tabs then need over
+ * 990dp before a single letter is measured, so the row overflowed and cut the last
+ * title in half.
+ *
+ * Here a tab is as wide as its own title plus a small padding, which is how the
+ * JavaFX TabPane sized them, and the row wraps to a second line when it truly does
+ * not fit. Wrapping rather than clipping is deliberate: half a word is the worst of
+ * the available outcomes, and a scrolling strip hides that tabs exist at all.
+ */
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun SettingsTabStrip(
+ tabs: List,
+ selectedIndex: Int,
+ onSelect: (Int) -> Unit,
+) {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ FlowRow(modifier = Modifier.fillMaxWidth()) {
+ tabs.forEachIndexed { index, tab ->
+ val isSelected = index == selectedIndex
+
+ val underline = MaterialTheme.colorScheme.primary
+
+ /*
+ * One box per tab, and the underline is painted inside it rather than
+ * stacked below it. A HorizontalDivider is fillMaxWidth by default, so
+ * putting one under each tab made every tab demand the full row width —
+ * and the row then wrapped after every single tab.
+ */
+ Box(
+ modifier = Modifier
+ .heightIn(min = Density.TAB_MIN_HEIGHT)
+ .background(
+ if (isSelected) {
+ MaterialTheme.colorScheme.secondaryContainer
+ } else {
+ Color.Transparent
+ }
+ )
+ .clickable { onSelect(index) }
+ .drawBehind {
+ if (isSelected) {
+ val thickness = 2.dp.toPx()
+ drawRect(
+ color = underline,
+ topLeft = Offset(0f, size.height - thickness),
+ size = Size(size.width, thickness),
+ )
+ }
+ }
+ .padding(horizontal = Density.TAB_HORIZONTAL_PADDING, vertical = 6.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ tab.title,
+ style = MaterialTheme.typography.bodyMedium,
+ maxLines = 1,
+ color = if (isSelected) {
+ MaterialTheme.colorScheme.onSecondaryContainer
+ } else {
+ MaterialTheme.colorScheme.onSurface
+ },
+ )
+ }
+ }
+ }
+
+ HorizontalDivider(thickness = Density.HAIRLINE)
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SnippetContextMenu.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SnippetContextMenu.kt
new file mode 100644
index 00000000..5a6a8f23
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SnippetContextMenu.kt
@@ -0,0 +1,30 @@
+package kst4contest.view.compose
+
+import androidx.compose.foundation.ContextMenuArea
+import androidx.compose.foundation.ContextMenuItem
+import androidx.compose.runtime.Composable
+
+/**
+ * The right-click menu of the station list and the message tables: one entry per
+ * configured snippet, appended to the message field when chosen.
+ *
+ * The same roster is reachable three ways and all three behave differently — see
+ * [ChatInputState.appendSnippet]. This is the one that appends the snippet alone.
+ *
+ * @param snippets the configured snippets, in the operator's own order
+ */
+@Composable
+fun SnippetContextMenu(
+ snippets: List,
+ state: ChatInputState,
+ content: @Composable () -> Unit,
+) {
+ ContextMenuArea(
+ items = {
+ snippets.map { snippet ->
+ ContextMenuItem(snippet) { state.appendSnippet(snippet) }
+ }
+ },
+ content = content,
+ )
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SplitterPane.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SplitterPane.kt
new file mode 100644
index 00000000..4a728496
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SplitterPane.kt
@@ -0,0 +1,136 @@
+package kst4contest.view.compose
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.gestures.detectDragGestures
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.pointer.PointerIcon
+import androidx.compose.ui.input.pointer.pointerHoverIcon
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.dp
+import java.awt.Cursor
+
+/** Which way the panes are stacked. */
+enum class SplitterOrientation { HORIZONTAL, VERTICAL }
+
+/**
+ * A splitter with draggable dividers, because Compose Multiplatform has none.
+ *
+ * The dividers look like the column handles of the tables — a hairline to see, a wider
+ * area to grab — so the two things an operator can drag in this window feel the same.
+ *
+ * @param panes one composable per pane; there is one divider less than there are panes.
+ * Must be as many as [SplitterState.paneCount] — a mismatch lays out silently
+ * wrong rather than failing, which is what MainWindowSplitterDefaultsTest guards
+ * for the three splitters of the main window.
+ */
+@Composable
+fun SplitterPane(
+ state: SplitterState,
+ orientation: SplitterOrientation,
+ panes: List<@Composable () -> Unit>,
+ modifier: Modifier = Modifier,
+) {
+ BoxWithConstraints(modifier) {
+ val density = LocalDensity.current
+ val handlePx = with(density) { Density.COLUMN_HANDLE_WIDTH.toPx() }
+ val fullPx = with(density) {
+ if (orientation == SplitterOrientation.HORIZONTAL) maxWidth.toPx() else maxHeight.toPx()
+ }
+
+ /*
+ * The handles are siblings of the panes, so what the panes share is what is left
+ * after them. The same span drives the drag, which keeps the divider under the
+ * cursor rather than drifting from it by a handle width per divider.
+ */
+ val totalPx = SplitterState.availableFor(fullPx, handlePx, state.paneCount)
+ val sizes = state.paneSizes(totalPx)
+
+ val body: @Composable () -> Unit = {
+ panes.forEachIndexed { index, pane ->
+ val sizeDp = with(density) { sizes.getOrElse(index) { 0f }.toDp() }
+
+ Box(
+ if (orientation == SplitterOrientation.HORIZONTAL) {
+ Modifier.width(sizeDp).fillMaxHeight()
+ } else {
+ Modifier.height(sizeDp).fillMaxWidth()
+ },
+ ) {
+ pane()
+ }
+
+ if (index < panes.size - 1) {
+ SplitterHandle(
+ state = state,
+ index = index,
+ orientation = orientation,
+ totalPx = totalPx,
+ )
+ }
+ }
+ }
+
+ if (orientation == SplitterOrientation.HORIZONTAL) {
+ Row(Modifier.fillMaxWidth().fillMaxHeight()) { body() }
+ } else {
+ Column(Modifier.fillMaxWidth().fillMaxHeight()) { body() }
+ }
+ }
+}
+
+/**
+ * One divider. The drag is converted to a fraction of the whole before it reaches the
+ * state, which is what keeps the stored value independent of the screen it was set on.
+ */
+@Composable
+private fun SplitterHandle(
+ state: SplitterState,
+ index: Int,
+ orientation: SplitterOrientation,
+ totalPx: Float,
+) {
+ val horizontal = orientation == SplitterOrientation.HORIZONTAL
+ val cursor = if (horizontal) Cursor.E_RESIZE_CURSOR else Cursor.N_RESIZE_CURSOR
+
+ Box(
+ modifier = Modifier
+ .then(
+ if (horizontal) Modifier.width(Density.COLUMN_HANDLE_WIDTH).fillMaxHeight()
+ else Modifier.height(Density.COLUMN_HANDLE_WIDTH).fillMaxWidth(),
+ )
+ .pointerHoverIcon(PointerIcon(Cursor(cursor)))
+ .pointerInput(index, totalPx) {
+ detectDragGestures(
+ onDragEnd = { state.dragFinished() },
+ onDragCancel = { state.dragFinished() },
+ ) { _, dragAmount ->
+ if (totalPx <= 0f) return@detectDragGestures
+ val delta = if (horizontal) dragAmount.x else dragAmount.y
+ val current = state.positions.getOrElse(index) { 0f }
+ state.drag(index, current + delta / totalPx, totalPx)
+ }
+ },
+ contentAlignment = Alignment.Center,
+ ) {
+ Box(
+ modifier = Modifier
+ .then(
+ if (horizontal) Modifier.width(Density.HAIRLINE).fillMaxHeight()
+ else Modifier.height(Density.HAIRLINE).fillMaxWidth(),
+ )
+ .background(MaterialTheme.colorScheme.outline),
+ )
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SplitterState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SplitterState.kt
new file mode 100644
index 00000000..dd962c65
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SplitterState.kt
@@ -0,0 +1,109 @@
+package kst4contest.view.compose
+
+import androidx.compose.runtime.mutableStateListOf
+
+/**
+ * The divider positions of one splitter, as fractions of its length.
+ *
+ * Compose Multiplatform brings no SplitPane, so this is ours. The positions are a
+ * setting, not decoration: an operator arranges the main window once for their screen
+ * and expects it back at the next contest.
+ *
+ * @param paneCount how many panes the splitter holds; there is one divider less
+ * @param stored the remembered positions, used only when they fit and make sense
+ * @param minPanePx the smallest a pane may be dragged to, so nothing vanishes by accident
+ * @param save called on release with the positions to remember — never during a drag,
+ * because the JavaFX version wrote the profile to disk on every pixel of one
+ */
+class SplitterState(
+ val paneCount: Int,
+ stored: DoubleArray,
+ private val minPanePx: Float = DEFAULT_MIN_PANE_PX,
+ private val save: (DoubleArray) -> Unit,
+) {
+
+ private val dividerCount = (paneCount - 1).coerceAtLeast(0)
+
+ private val livePositions = mutableStateListOf().apply {
+ addAll(sanitize(stored, dividerCount))
+ }
+
+ /** What the draw reads. Compose state, so a drag reaches the screen. */
+ val positions: List get() = livePositions
+
+ private var savedPositions: List = livePositions.toList()
+
+ /**
+ * Moves one divider, held off its neighbours and off the minimum pane size.
+ *
+ * @param to the wanted position as a fraction of the whole
+ * @param totalPx the splitter's length, needed to turn the minimum into a fraction
+ */
+ fun drag(index: Int, to: Float, totalPx: Float) {
+ if (index !in 0 until dividerCount) return
+
+ val minFraction = if (totalPx > 0f) minPanePx / totalPx else 0f
+ val lowerBound = (if (index == 0) 0f else livePositions[index - 1]) + minFraction
+ val upperBound = (if (index == dividerCount - 1) 1f else livePositions[index + 1]) - minFraction
+
+ // A window too narrow for the minimums would give an empty range; splitting the
+ // remaining room is better than laying out nothing.
+ livePositions[index] = if (lowerBound > upperBound) {
+ (lowerBound + upperBound) / 2f
+ } else {
+ to.coerceIn(lowerBound, upperBound)
+ }
+ }
+
+ /** Remembers the positions, but only if the drag actually moved something. */
+ fun dragFinished() {
+ val current = livePositions.toList()
+ if (current == savedPositions) return
+ savedPositions = current
+ save(DoubleArray(current.size) { current[it].toDouble() })
+ }
+
+ /** The pane lengths for a given splitter length; they always add up to the whole. */
+ fun paneSizes(totalPx: Float): List {
+ if (paneCount <= 1) return listOf(totalPx)
+ val edges = buildList {
+ add(0f)
+ livePositions.forEach { add(it * totalPx) }
+ add(totalPx)
+ }
+ return (0 until paneCount).map { edges[it + 1] - edges[it] }
+ }
+
+ companion object {
+
+ /** Enough to still see a table header and grab the divider again. */
+ const val DEFAULT_MIN_PANE_PX = 24f
+
+ /**
+ * How much length the panes may share, once the divider handles have taken theirs.
+ *
+ * The panes and the handles are laid out as siblings, so distributing the whole
+ * length among the panes overflows the splitter by one handle width per divider —
+ * 20 dp for the five-pane message column, enough to push the bottom pane off the
+ * edge. Never negative: a splitter smaller than its own handles hands out nothing.
+ */
+ fun availableFor(totalPx: Float, dividerPx: Float, paneCount: Int): Float {
+ val dividerCount = (paneCount - 1).coerceAtLeast(0)
+ return (totalPx - dividerPx * dividerCount).coerceAtLeast(0f)
+ }
+
+ /**
+ * The remembered positions are used only when there are exactly as many as there
+ * are dividers, each inside the pane and in ascending order. Anything else is a
+ * profile from an older layout, and an even split beats a window whose panes have
+ * no size.
+ */
+ internal fun sanitize(stored: DoubleArray, dividerCount: Int): List {
+ val evenSplit = (1..dividerCount).map { it.toFloat() / (dividerCount + 1) }
+ if (stored.size != dividerCount) return evenSplit
+ if (stored.any { !it.isFinite() || it <= 0.0 || it >= 1.0 }) return evenSplit
+ if (stored.toList() != stored.sorted()) return evenSplit
+ return stored.map { it.toFloat() }
+ }
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/StationColumns.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationColumns.kt
new file mode 100644
index 00000000..22e7650c
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationColumns.kt
@@ -0,0 +1,154 @@
+package kst4contest.view.compose
+
+import kst4contest.model.ChatMember
+import java.util.EnumSet
+
+/**
+ * The columns of the station list.
+ *
+ * The ids are not free to choose: they are the operator's stored column widths, so
+ * renaming one loses their layout without a word. They are kept exactly as
+ * `TableLayoutManager.column(...)` registered them.
+ *
+ * Every value survives a half-filled station. These rows come off the wire, a field
+ * can be missing, and a crash in the station list during a contest is the worst
+ * possible moment for one.
+ *
+ * @param tropoOf the reachability the controller computes; not carried by the row
+ * @param priorityScoreOf the score the row is coloured by, also computed outside
+ * @param formatBand formats a single band cell (worked / new / etc.)
+ * @param activeBands the bands the operator has enabled in settings; only these
+ * get a worked column. Matches the JavaFX behaviour where the column is
+ * only added when `isStn_bandActiveXX()` returns true.
+ */
+object StationColumns {
+
+ /** The heading the ten worked columns sit under. */
+ const val WORKED_GROUP = "worked"
+
+ fun all(
+ tropoOf: (ChatMember) -> String,
+ priorityScoreOf: (ChatMember) -> Double,
+ formatBand: (ChatMember, kst4contest.model.Band, Boolean) -> String,
+ activeBands: EnumSet = EnumSet.allOf(kst4contest.model.Band::class.java),
+ ): List> = buildList {
+ add(DataColumn(id = "callsign", title = "Callsign", value = { it.callSign.orEmpty() }))
+ add(DataColumn(id = "name", title = "Name", value = { it.name.orEmpty() }))
+ add(DataColumn(id = "qra", title = "QRA", value = { it.qra.orEmpty() }, weight = 0.8f))
+ add(DataColumn(
+ id = "qrb",
+ title = "QRB",
+ value = { it.qrb?.toInt()?.toString().orEmpty() },
+ comparator = compareBy { it.qrb ?: 0.0 },
+ weight = 0.6f,
+ ))
+ add(DataColumn(
+ id = "qtf",
+ title = "QTF",
+ value = { it.getQTFdirection()?.let { d -> String.format("%.2f°", d) }.orEmpty() },
+ comparator = compareBy { it.getQTFdirection() ?: 0.0 },
+ weight = 0.7f,
+ ))
+ add(DataColumn(id = "qrg", title = "QRG", value = { it.frequency?.get().orEmpty() }, weight = 0.8f))
+ add(DataColumn(
+ id = "airscout",
+ title = "AP [minutes / pot%]",
+ value = { AirplaneCell.format(it.airPlaneReflectInfo) },
+ accent = { _, rendered -> AirplaneCell.accentFor(rendered) },
+ weight = 1.4f,
+ ))
+ add(DataColumn(id = "tropo", title = "Tropo", value = tropoOf, weight = 0.7f))
+ add(DataColumn(
+ id = "score",
+ title = "Score",
+ value = { priorityScoreOf(it).toInt().toString() },
+ comparator = compareBy { priorityScoreOf(it) },
+ weight = 0.6f,
+ ))
+ add(DataColumn(
+ id = "activity",
+ title = "Act",
+ value = { minutesSince(it.activityTimeLastInEpoch) },
+ comparator = compareBy { it.activityTimeLastInEpoch },
+ weight = 0.5f,
+ ))
+
+ /* Individual band columns only when the operator has that band enabled. */
+ if (kst4contest.model.Band.B_50 in activeBands)
+ add(workedColumn("band-50", "50") { formatBand(it, kst4contest.model.Band.B_50, it.isWorked50) })
+ if (kst4contest.model.Band.B_70 in activeBands)
+ add(workedColumn("band-70", "70") { formatBand(it, kst4contest.model.Band.B_70, it.isWorked70) })
+ if (kst4contest.model.Band.B_144 in activeBands)
+ add(workedColumn("band-144", "144") { formatBand(it, kst4contest.model.Band.B_144, it.isWorked144) })
+ if (kst4contest.model.Band.B_432 in activeBands)
+ add(workedColumn("band-432", "432") { formatBand(it, kst4contest.model.Band.B_432, it.isWorked432) })
+ if (kst4contest.model.Band.B_1296 in activeBands)
+ add(workedColumn("band-1296", "23") { formatBand(it, kst4contest.model.Band.B_1296, it.isWorked1240) })
+ if (kst4contest.model.Band.B_2320 in activeBands)
+ add(workedColumn("band-2320", "13") { formatBand(it, kst4contest.model.Band.B_2320, it.isWorked2300) })
+ if (kst4contest.model.Band.B_3400 in activeBands)
+ add(workedColumn("band-3400", "9") { formatBand(it, kst4contest.model.Band.B_3400, it.isWorked3400) })
+ if (kst4contest.model.Band.B_5760 in activeBands)
+ add(workedColumn("band-5760", "6") { formatBand(it, kst4contest.model.Band.B_5760, it.isWorked5600) })
+ if (kst4contest.model.Band.B_10G in activeBands)
+ add(workedColumn("band-10g", "3") { formatBand(it, kst4contest.model.Band.B_10G, it.isWorked10G) })
+
+ /* Worked-any is always shown, and belongs under the 'worked' group header just like in JavaFX. */
+ add(workedColumn("worked-any", "wkdany", group = WORKED_GROUP, weight = 0.7f) { if (it.isWorked) "X" else "" })
+
+ add(DataColumn(id = "not-qrv", title = "NOT QRV @", value = { qrvBands(it, activeBands) }, weight = 1.6f))
+ add(DataColumn(
+ id = "category",
+ title = "Category",
+ value = { member ->
+ member.chatCategory?.let { it.getChatCategoryName(it.categoryNumber) }.orEmpty()
+ },
+ weight = 1.2f,
+ ))
+ }
+
+ /** A cross or nothing, and under the shared heading. */
+ private fun workedColumn(
+ id: String,
+ title: String,
+ group: String? = WORKED_GROUP,
+ weight: Float = 0.35f,
+ worked: (ChatMember) -> String,
+ ): DataColumn = DataColumn(
+ id = id,
+ title = title,
+ value = worked,
+ weight = weight,
+ group = group,
+ )
+
+ /** How long ago the station was last heard, in whole minutes. */
+ private fun minutesSince(epochSeconds: Long): String {
+ if (epochSeconds <= 0L) {
+ return ""
+ }
+
+ val seconds = (System.currentTimeMillis() / 1000L) - epochSeconds
+
+ return if (seconds < 0) "" else (seconds / 60L).toString()
+ }
+
+ /**
+ * The bands the station is **not** on, which is what the heading says.
+ *
+ * Every QRV flag starts true — a station is assumed to be on every band until it
+ * says otherwise — so this column is empty for most rows and fills as the operator
+ * or the server marks bands off.
+ */
+ private fun qrvBands(member: ChatMember, activeBands: Set): String = buildString {
+ if (!member.isQrv50 && kst4contest.model.Band.B_50 in activeBands) append("6m ")
+ if (!member.isQrv70 && kst4contest.model.Band.B_70 in activeBands) append("4m ")
+ if (!member.isQrv144 && kst4contest.model.Band.B_144 in activeBands) append("2m ")
+ if (!member.isQrv432 && kst4contest.model.Band.B_432 in activeBands) append("70cm ")
+ if (!member.isQrv1240 && kst4contest.model.Band.B_1296 in activeBands) append("23cm ")
+ if (!member.isQrv2300 && kst4contest.model.Band.B_2320 in activeBands) append("13cm ")
+ if (!member.isQrv3400 && kst4contest.model.Band.B_3400 in activeBands) append("9cm ")
+ if (!member.isQrv5600 && kst4contest.model.Band.B_5760 in activeBands) append("6cm ")
+ if (!member.isQrv10G && kst4contest.model.Band.B_10G in activeBands) append("3cm ")
+ }.trim()
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterBar.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterBar.kt
new file mode 100644
index 00000000..0d8f32ce
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterBar.kt
@@ -0,0 +1,279 @@
+package kst4contest.view.compose
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.IntrinsicSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.luminance
+import androidx.compose.ui.unit.dp
+
+/**
+ * The station filter bar above the station table.
+ *
+ * Laid out to match the JavaFX original: three tight rows of controls separated
+ * by thin vertical rules between logical groups.
+ */
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+fun StationFilterBar(
+ filters: StationFilterState,
+ activeBands: Set,
+ onChanged: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 1.dp),
+ verticalArrangement = Arrangement.spacedBy(1.dp),
+ ) {
+ // ROW 1: Reset | QRB | QTF + bearings
+ Row(
+ modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ // Reset filters
+ FilterButton("Reset filters", on = false, isAccent = true) {
+ filters.reset()
+ onChanged()
+ }
+
+ VerticalSeparator()
+
+ // QRB checkbox + field
+ CompactCheckbox(
+ checked = filters.maxQrbEnabled,
+ onCheckedChange = {
+ filters.maxQrbEnabled = it
+ onChanged()
+ },
+ )
+ Text("Show only QRB [km] <=", style = MaterialTheme.typography.bodySmall)
+ CompactTextField(
+ value = if (filters.maxQrbKm.rem(1) == 0.0) filters.maxQrbKm.toInt().toString() else filters.maxQrbKm.toString(),
+ onValueChange = {
+ val parsed = it.toDoubleOrNull()
+ if (parsed != null) {
+ filters.maxQrbKm = parsed
+ if (filters.maxQrbEnabled) onChanged()
+ } else if (it.isEmpty()) {
+ filters.maxQrbKm = 0.0
+ if (filters.maxQrbEnabled) onChanged()
+ }
+ },
+ modifier = Modifier.width(50.dp)
+ )
+
+ VerticalSeparator()
+
+ // QTF checkbox + field + bearings
+ CompactCheckbox(
+ checked = filters.qtfEnabled,
+ onCheckedChange = {
+ filters.qtfEnabled = it
+ onChanged()
+ },
+ )
+ Text("Show only QTF:", style = MaterialTheme.typography.bodySmall)
+ CompactTextField(
+ value = if (filters.qtfDegrees.rem(1) == 0.0) filters.qtfDegrees.toInt().toString() else filters.qtfDegrees.toString(),
+ onValueChange = {
+ val parsed = it.toDoubleOrNull()
+ if (parsed != null) {
+ filters.qtfDegrees = parsed
+ filters.bearing = null
+ if (filters.qtfEnabled) onChanged()
+ } else if (it.isEmpty()) {
+ filters.qtfDegrees = 0.0
+ filters.bearing = null
+ if (filters.qtfEnabled) onChanged()
+ }
+ },
+ modifier = Modifier.width(50.dp)
+ )
+ Text("± BW/2", style = MaterialTheme.typography.bodySmall)
+
+ Bearing.entries.forEach { bearing ->
+ FilterButton(bearing.name, on = filters.bearing == bearing) {
+ filters.toggleBearing(bearing)
+ onChanged()
+ }
+ }
+ }
+
+ // ROW 2: Find | Hide worked: wkd 50 70 144 432 | Inactive stations | Only new grids
+ Row(
+ modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ CompactTextField(
+ value = filters.searchText,
+ onValueChange = {
+ filters.searchText = it
+ onChanged()
+ },
+ placeholder = "Find...",
+ modifier = Modifier.width(90.dp),
+ )
+
+ VerticalSeparator()
+
+ Text("Hide worked:", style = MaterialTheme.typography.bodySmall)
+ FilterButton("wkdany", on = filters.hideWorkedAny) {
+ filters.hideWorkedAny = !filters.hideWorkedAny
+ onChanged()
+ }
+ WorkedBandFilter.entries.forEach { band ->
+ if (band.modelBand in activeBands) {
+ FilterButton(band.label, on = filters.isHideWorkedOn(band)) {
+ filters.setHideWorkedOn(band, !filters.isHideWorkedOn(band))
+ onChanged()
+ }
+ }
+ }
+
+ VerticalSeparator()
+
+ FilterButton("Inactive stations", on = filters.hideInactive) {
+ filters.hideInactive = !filters.hideInactive
+ onChanged()
+ }
+ FilterButton("Only new grids", on = filters.onlyNewGrids) {
+ filters.onlyNewGrids = !filters.onlyNewGrids
+ onChanged()
+ }
+ }
+
+ // ROW 3: Grid color | Tropo | New bands | AS next 5m
+ Row(
+ modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min),
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ FilterButton("Grid color", on = filters.gridColouring) {
+ filters.gridColouring = !filters.gridColouring
+ }
+ FilterButton("Tropo >=0dB", on = filters.tropoReachable) {
+ filters.tropoReachable = !filters.tropoReachable
+ onChanged()
+ }
+ FilterButton("New bands", on = filters.newBands) {
+ filters.newBands = !filters.newBands
+ onChanged()
+ }
+ FilterButton("AS next 5m", on = filters.airScoutNext5Min) {
+ filters.airScoutNext5Min = !filters.airScoutNext5Min
+ onChanged()
+ }
+ }
+ }
+}
+
+/**
+ * A compact toggle button that does NOT use Material 3 Button (which enforces 40dp).
+ * Instead it is a plain clickable Box with border and background, matching the tiny
+ * JavaFX filter buttons.
+ */
+@Composable
+private fun FilterButton(label: String, on: Boolean, isAccent: Boolean = false, onClick: () -> Unit) {
+ val palette = LocalJavaFxPalette.current
+ val isDark = palette.windowBackground.luminance() < 0.5f
+
+ val accentBrush = if (isDark) {
+ androidx.compose.ui.graphics.Brush.linearGradient(
+ colors = listOf(Color(0xFF008000), Color(0xFF90EE90))
+ )
+ } else {
+ androidx.compose.ui.graphics.Brush.linearGradient(
+ colors = listOf(Color(0xFF00FFFF), Color(0xFFFF99FF))
+ )
+ }
+
+ val bgModifier = if (isAccent) {
+ Modifier.background(accentBrush, Density.BUTTON_SHAPE)
+ } else {
+ val bg = if (on) MaterialTheme.colorScheme.primary else Color.Transparent
+ Modifier.background(bg, Density.BUTTON_SHAPE)
+ }
+
+ val fg = if (isAccent) Color(0xFF395306) else if (on) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurface
+ val borderColor = if (isAccent) Color.Transparent else if (on) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
+
+ Box(
+ modifier = Modifier
+ .heightIn(min = 20.dp)
+ .then(if (borderColor != Color.Transparent) Modifier.border(Density.HAIRLINE, borderColor, Density.BUTTON_SHAPE) else Modifier)
+ .then(bgModifier)
+ .clickable { onClick() }
+ .padding(horizontal = 6.dp, vertical = 2.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(label, style = MaterialTheme.typography.bodySmall, color = fg, maxLines = 1)
+ }
+}
+
+/** Thin vertical separator matching JavaFX's group dividers. */
+@Composable
+private fun VerticalSeparator() {
+ Box(
+ modifier = Modifier
+ .width(Density.HAIRLINE)
+ .fillMaxHeight()
+ .background(MaterialTheme.colorScheme.outline),
+ )
+}
+
+
+@Composable
+private fun CompactTextField(
+ value: String,
+ onValueChange: (String) -> Unit,
+ placeholder: String = "",
+ modifier: Modifier = Modifier,
+) {
+ BasicTextField(
+ value = value,
+ onValueChange = onValueChange,
+ singleLine = true,
+ textStyle = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurface),
+ decorationBox = { innerTextField ->
+ Box(
+ modifier = modifier
+ .background(MaterialTheme.colorScheme.surfaceVariant, Density.BUTTON_SHAPE)
+ .border(1.dp, MaterialTheme.colorScheme.outline, Density.BUTTON_SHAPE)
+ .padding(horizontal = 4.dp, vertical = 2.dp),
+ contentAlignment = Alignment.CenterStart,
+ ) {
+ if (value.isEmpty() && placeholder.isNotEmpty()) {
+ Text(
+ placeholder,
+ style = MaterialTheme.typography.bodySmall.copy(
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ ),
+ )
+ }
+ innerTextField()
+ }
+ },
+ )
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterState.kt
new file mode 100644
index 00000000..b2f75d08
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterState.kt
@@ -0,0 +1,161 @@
+package kst4contest.view.compose
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateMapOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+
+/**
+ * The filters the station list can have switched on.
+ *
+ * One entry per predicate the JavaFX bar added to the controller. The controller does
+ * the filtering — it has since Etappe 2 — so this names which predicates should be
+ * attached, and nothing more.
+ */
+enum class StationFilter {
+ ONLY_NEW_GRIDS,
+ TROPO_REACHABLE,
+ NEW_BANDS,
+ AIRSCOUT_NEXT_5_MIN,
+ MAX_QRB,
+ QTF,
+ SEARCH_TEXT,
+ HIDE_INACTIVE,
+ HIDE_WORKED_ANY,
+ HIDE_WORKED_50,
+ HIDE_WORKED_70,
+ HIDE_WORKED_144,
+ HIDE_WORKED_432,
+ HIDE_WORKED_23,
+ HIDE_WORKED_13,
+ HIDE_WORKED_9,
+ HIDE_WORKED_6,
+ HIDE_WORKED_3,
+}
+
+/** The bands the "hide worked" row offers, under the labels the bar shows. */
+enum class WorkedBandFilter(val label: String, val filter: StationFilter, val modelBand: kst4contest.model.Band) {
+ B_50("50", StationFilter.HIDE_WORKED_50, kst4contest.model.Band.B_50),
+ B_70("70", StationFilter.HIDE_WORKED_70, kst4contest.model.Band.B_70),
+ B_144("144", StationFilter.HIDE_WORKED_144, kst4contest.model.Band.B_144),
+ B_432("432", StationFilter.HIDE_WORKED_432, kst4contest.model.Band.B_432),
+ B_23("23", StationFilter.HIDE_WORKED_23, kst4contest.model.Band.B_1296),
+ B_13("13", StationFilter.HIDE_WORKED_13, kst4contest.model.Band.B_2320),
+ B_9("9", StationFilter.HIDE_WORKED_9, kst4contest.model.Band.B_3400),
+ B_6("6", StationFilter.HIDE_WORKED_6, kst4contest.model.Band.B_5760),
+ B_3("3", StationFilter.HIDE_WORKED_3, kst4contest.model.Band.B_10G),
+}
+
+/**
+ * The eight bearing buttons, with the degrees each writes into the QTF field.
+ *
+ * They were one ToggleGroup, so only one can be picked, and picking one filters
+ * nothing by itself — it sets a bearing the QTF filter uses once that is switched on.
+ */
+enum class Bearing(val degrees: Double) {
+ N(0.0), NE(45.0), E(90.0), SE(135.0), S(180.0), SW(225.0), W(270.0), NW(315.0)
+}
+
+/**
+ * What the filter bar has switched on.
+ *
+ * Deliberately free of the drawing: its 25 toggles are not 25 states, and which of
+ * them mean what is worth pinning where it can be tested. Getting it wrong puts a
+ * station in front of the operator that their filter excludes, or hides one it does
+ * not — and in a contest that is a missed contact either way.
+ */
+class StationFilterState {
+
+ private val switched = mutableStateMapOf()
+
+ var onlyNewGrids: Boolean
+ get() = isOn(StationFilter.ONLY_NEW_GRIDS)
+ set(value) = switch(StationFilter.ONLY_NEW_GRIDS, value)
+
+ var tropoReachable: Boolean
+ get() = isOn(StationFilter.TROPO_REACHABLE)
+ set(value) = switch(StationFilter.TROPO_REACHABLE, value)
+
+ var newBands: Boolean
+ get() = isOn(StationFilter.NEW_BANDS)
+ set(value) = switch(StationFilter.NEW_BANDS, value)
+
+ var airScoutNext5Min: Boolean
+ get() = isOn(StationFilter.AIRSCOUT_NEXT_5_MIN)
+ set(value) = switch(StationFilter.AIRSCOUT_NEXT_5_MIN, value)
+
+ var hideInactive: Boolean
+ get() = isOn(StationFilter.HIDE_INACTIVE)
+ set(value) = switch(StationFilter.HIDE_INACTIVE, value)
+
+ var hideWorkedAny: Boolean
+ get() = isOn(StationFilter.HIDE_WORKED_ANY)
+ set(value) = switch(StationFilter.HIDE_WORKED_ANY, value)
+
+ var maxQrbEnabled: Boolean
+ get() = isOn(StationFilter.MAX_QRB)
+ set(value) = switch(StationFilter.MAX_QRB, value)
+
+ /** Only meaningful while [maxQrbEnabled]; a distance alone filters nothing. */
+ var maxQrbKm: Double by mutableStateOf(0.0)
+
+ var qtfEnabled: Boolean
+ get() = isOn(StationFilter.QTF)
+ set(value) = switch(StationFilter.QTF, value)
+
+ var qtfDegrees: Double by mutableStateOf(0.0)
+
+ var bearing: Bearing? by mutableStateOf(null)
+
+ /**
+ * Colours the locator cell by grid status. **Not a filter** — the JavaFX toggle
+ * sat among the filter buttons but only redrew the table, and a station must not
+ * disappear because of it.
+ */
+ var gridColouring: Boolean by mutableStateOf(false)
+
+ private var searchTextValue: String by mutableStateOf("")
+
+ var searchText: String
+ get() = searchTextValue
+ set(value) {
+ searchTextValue = value
+ /* Blank is not a search: leaving it on would hide every station. */
+ switch(StationFilter.SEARCH_TEXT, value.isNotBlank())
+ }
+
+ /** The filters that should be attached to the controller right now. */
+ val activeFilters: Set
+ get() = switched.filterValues { it }.keys
+
+ fun isOn(filter: StationFilter): Boolean = switched[filter] == true
+
+ fun setHideWorkedOn(band: WorkedBandFilter, hide: Boolean) = switch(band.filter, hide)
+
+ fun isHideWorkedOn(band: WorkedBandFilter): Boolean = isOn(band.filter)
+
+ /** Picks one bearing and writes its degrees, or deselects if already selected.
+ * Mirrors JavaFX ToggleGroup behavior which also toggles the QTF filter checkbox. */
+ fun toggleBearing(direction: Bearing) {
+ if (bearing == direction) {
+ bearing = null
+ qtfEnabled = false
+ } else {
+ bearing = direction
+ qtfDegrees = direction.degrees
+ qtfEnabled = true
+ }
+ }
+
+ private fun switch(filter: StationFilter, on: Boolean) {
+ if (on) switched[filter] = true else switched.remove(filter)
+ }
+
+ fun reset() {
+ switched.clear()
+ searchText = ""
+ qtfDegrees = 0.0
+ bearing = null
+ gridColouring = false
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/StationRowStyle.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationRowStyle.kt
new file mode 100644
index 00000000..8d70b7a6
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationRowStyle.kt
@@ -0,0 +1,42 @@
+package kst4contest.view.compose
+
+import androidx.compose.ui.graphics.Color
+import kst4contest.model.ChatMember
+
+/**
+ * The row colouring of the station table (chat members).
+ *
+ * Mimics JavaFX Kst4ContestApplication.java:2663
+ */
+object StationRowStyle {
+
+ // > 1000 = NUCLEAR (Imminent Sked) -> Blinking Red (simulated here with solid red)
+ val SCORE_NUCLEAR_BG = Color(0xFFFF4D4D)
+ val SCORE_NUCLEAR_FG = Color.White
+
+ // > 200 = High Prio (AirScout / Good Sked) -> Orange
+ // > 100 = Medium Prio (Unworked / New Multi) -> Light Yellow
+ // <= 0 = Low Prio / Not Reachable -> Greyed out text
+
+ // We only hardcode light mode colors here to be safe and match JavaFX for now.
+ // In JavaFX it checked `isGUI_darkModeActive()`, but Compose might have its own dark mode check.
+ val SCORE_HIGH_BG = Color(0xFFFFCC00)
+ val SCORE_HIGH_FG = Color.Black
+
+ val SCORE_MEDIUM_BG = Color(0xFFFFFFCC)
+ val SCORE_MEDIUM_FG = Color.Black
+
+ val SCORE_LOW_FG = Color(0xFFAAAAAA)
+
+ fun accentFor(member: ChatMember, isDark: Boolean = false): CellAccent? {
+ val score = member.currentPriorityScore
+
+ return when {
+ score > 1000 -> CellAccent(background = SCORE_NUCLEAR_BG, foreground = SCORE_NUCLEAR_FG)
+ score >= 200 -> CellAccent(background = if (isDark) Color(0xFFCC6600) else SCORE_HIGH_BG, foreground = SCORE_HIGH_FG)
+ score >= 100 -> CellAccent(background = if (isDark) Color(0xFF888800) else SCORE_MEDIUM_BG, foreground = SCORE_MEDIUM_FG)
+ score <= 0 -> CellAccent(background = null, foreground = if (isDark) Color(0xFF666666) else SCORE_LOW_FG)
+ else -> null
+ }
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/StatusBar.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/StatusBar.kt
new file mode 100644
index 00000000..0eb2dbfa
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/StatusBar.kt
@@ -0,0 +1,242 @@
+package kst4contest.view.compose
+
+import androidx.compose.animation.core.Animatable
+import androidx.compose.animation.core.keyframes
+import androidx.compose.animation.core.repeatable
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.TooltipArea
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import kotlinx.coroutines.delay
+import kst4contest.controller.On4KstConnectionState
+
+/**
+ * The status bar along the top of the main window.
+ *
+ * In JavaFX this was one FlowPane holding the menu bar and the indicators side by side.
+ * Compose puts the menu bar in the window's own scope (see [Kst4ContestMenuBar]), so
+ * only the indicators are left here — which is also what finally makes the bar wrap
+ * properly on a narrow window instead of pushing the badges out of sight.
+ *
+ * Nothing in this bar is clickable. The three indicators were mouse-transparent and
+ * non-focusable in JavaFX and stay that way: the operator is typing into the chat, and a
+ * status badge must never take the keyboard. The background-thread buttons were ordinary
+ * focusable Buttons there, with no action attached — they are drawn as plain labels here,
+ * which is what they always behaved like.
+ */
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+fun StatusBar(
+ connectionState: On4KstConnectionState?,
+ connectionDetail: String?,
+ skedNotice: BlinkingNotice,
+ bandUpgradeNotice: BlinkingNotice,
+ threadButtons: ThreadStatusButtons,
+ showConnectionBadge: Boolean = true,
+ modifier: Modifier = Modifier,
+) {
+ FlowRow(
+ modifier = modifier.padding(horizontal = 4.dp, vertical = 2.dp),
+ horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
+ verticalArrangement = Arrangement.spacedBy(2.dp),
+ ) {
+ // On macOS the connection state lives in the system menu bar instead.
+ if (showConnectionBadge) {
+ ConnectionBadgeView(connectionState, connectionDetail)
+ }
+
+ BlinkingNoticeView(skedNotice, SKED_BACKGROUND)
+ BlinkingNoticeView(bandUpgradeNotice, BAND_UPGRADE_BACKGROUND)
+
+ threadButtons.buttons.forEach { ThreadStatusButtonView(it) }
+ }
+}
+
+/** rgba(255,0,255,0.85) — the sked reminder, the one colour nothing else in the UI uses. */
+private val SKED_BACKGROUND = Color(0xD9FF00FF)
+
+/** rgba(255,255,0,0.85) — the band upgrade hint. */
+private val BAND_UPGRADE_BACKGROUND = Color(0xD9FFFF00)
+
+private val NOTICE_SHAPE = RoundedCornerShape(6.dp)
+private val NOTICE_PADDING = PaddingValues(horizontal = 8.dp, vertical = 2.dp)
+
+/**
+ * The connection badge, at least as large as JavaFX pinned it (44 × 22).
+ *
+ * A minimum and not a fixed size: JavaFX also set setMaxSize(44,22), which this does not, so
+ * a larger base font grows the badge rather than clipping its label. The three labels are
+ * within a character of each other, so the bar barely reflows either way.
+ */
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun ConnectionBadgeView(state: On4KstConnectionState?, detail: String?) {
+ val badge = ConnectionIndicator.badgeFor(state)
+ val colors = badgeColors(badge)
+
+ TooltipArea(tooltip = { TooltipCard(ConnectionIndicator.tooltipFor(state, detail)) }) {
+ Box(
+ modifier = Modifier
+ .defaultMinSize(minWidth = 44.dp, minHeight = 22.dp)
+ .background(colors.background, NOTICE_SHAPE)
+ .border(2.dp, colors.border, NOTICE_SHAPE)
+ .padding(horizontal = 5.dp, vertical = 1.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = badge.label,
+ color = colors.text,
+ fontSize = 10.sp,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ )
+ }
+ }
+}
+
+private class BadgeColors(val background: Color, val border: Color, val text: Color)
+
+/**
+ * The badge colours are deliberately not taken from the theme. They are traffic-light
+ * colours and have to mean the same thing in both designs; a red that turns into the
+ * dark theme's accent would be unreadable as a warning.
+ */
+private fun badgeColors(badge: ConnectionBadge): BadgeColors = when (badge) {
+ ConnectionBadge.ONLINE -> BadgeColors(Color(0xFF238636), Color(0xFF56D364), Color.White)
+ ConnectionBadge.BUSY -> BadgeColors(Color(0xFFFFB300), Color(0xFFFFE082), Color(0xFF1B1B1B))
+ ConnectionBadge.OFFLINE -> BadgeColors(Color(0xFFD50000), Color(0xFFFF6B6B), Color.White)
+}
+
+/**
+ * A notice that blinks and then removes itself.
+ *
+ * The blink is the JavaFX timeline: bright, quarter-dim after 250 ms, bright again at
+ * 500 ms, twenty-four times, then gone. It is long enough to catch the eye of someone
+ * looking at the chat, and short enough not to become furniture.
+ */
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun BlinkingNoticeView(notice: BlinkingNotice, background: Color) {
+ if (!notice.visible) return
+
+ val alpha = remember { Animatable(1f) }
+
+ LaunchedEffect(notice.revision) {
+ val generation = notice.revision
+ try {
+ alpha.snapTo(1f)
+ alpha.animateTo(
+ targetValue = 1f,
+ animationSpec = repeatable(
+ iterations = BlinkingNotice.BLINK_CYCLES,
+ animation = keyframes {
+ durationMillis = BlinkingNotice.BLINK_CYCLE_MILLIS.toInt()
+ 1f at 0
+ 0.25f at 250
+ 1f at BlinkingNotice.BLINK_CYCLE_MILLIS.toInt()
+ },
+ ),
+ )
+ } finally {
+ // Also on cancellation: a blink cut short by the bar leaving the composition
+ // would otherwise leave the notice showing for the rest of the session.
+ notice.hide(generation)
+ }
+ }
+
+ TooltipArea(tooltip = { TooltipCard(notice.tooltip) }) {
+ Box(
+ modifier = Modifier
+ .alpha(alpha.value)
+ .background(background, NOTICE_SHAPE)
+ .padding(NOTICE_PADDING),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = notice.text,
+ color = Color.Black,
+ fontWeight = FontWeight.Bold,
+ style = MaterialTheme.typography.labelMedium,
+ maxLines = 1,
+ )
+ }
+ }
+}
+
+/**
+ * One background thread's state. It flashes in the accent colour whenever a message
+ * arrives, which is how a running worker is told from a stuck one at a glance.
+ */
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun ThreadStatusButtonView(button: ThreadStatusButton) {
+ LaunchedEffect(button.revision) {
+ try {
+ delay(ThreadStatusButtons.HIGHLIGHT_MILLIS)
+ } finally {
+ // On cancellation too, so a button that left the composition mid flash does not
+ // come back still lit.
+ button.clearHighlight()
+ }
+ }
+
+ val scheme = MaterialTheme.colorScheme
+ val background = if (button.highlighted) scheme.primary else scheme.surfaceVariant
+ val foreground = if (button.highlighted) scheme.onPrimary else scheme.onSurfaceVariant
+
+ TooltipArea(tooltip = { TooltipCard(button.tooltip) }) {
+ Box(
+ modifier = Modifier
+ .defaultMinSize(minHeight = 22.dp)
+ .background(background, NOTICE_SHAPE)
+ .padding(NOTICE_PADDING),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = button.label,
+ color = foreground,
+ style = MaterialTheme.typography.labelMedium,
+ maxLines = 1,
+ )
+ }
+ }
+}
+
+/** The tooltips in this bar carry the long text the badges had to cut. */
+@Composable
+internal fun TooltipCard(text: String) {
+ if (text.isBlank()) return
+ Surface(
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ shape = Density.BUTTON_SHAPE,
+ tonalElevation = 4.dp,
+ ) {
+ Text(
+ text = text,
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/StatusIndicatorState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/StatusIndicatorState.kt
new file mode 100644
index 00000000..538aaab2
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/StatusIndicatorState.kt
@@ -0,0 +1,225 @@
+package kst4contest.view.compose
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import kst4contest.controller.On4KstConnectionState
+import kst4contest.model.ThreadStateMessage
+import java.util.Locale
+
+/**
+ * How the connection indicator reads at a glance.
+ *
+ * Three badges for nine states: mid contest nobody parses "SYNCING_SECOND_CHAT". What
+ * the operator needs to know is whether something can be sent right now.
+ */
+enum class ConnectionBadge(val label: String) {
+ ONLINE("LINK"),
+ BUSY("LINK…"),
+ OFFLINE("LINK!"),
+}
+
+/** The badge, its tooltip and the macOS menu title, all derived from one state. */
+object ConnectionIndicator {
+
+ /** No state at all is the state before the first callback, and that is not a link. */
+ fun badgeFor(state: On4KstConnectionState?): ConnectionBadge =
+ when (state ?: On4KstConnectionState.DISCONNECTED) {
+ On4KstConnectionState.ONLINE -> ConnectionBadge.ONLINE
+
+ On4KstConnectionState.CONNECTING,
+ On4KstConnectionState.WAITING_FOR_LOGIN_PROMPT,
+ On4KstConnectionState.AUTHENTICATING,
+ On4KstConnectionState.SYNCING_MAIN_CHAT,
+ On4KstConnectionState.SYNCING_SECOND_CHAT,
+ On4KstConnectionState.STOPPING -> ConnectionBadge.BUSY
+
+ // A scheduled reconnect is not a working link, so it reads as offline.
+ On4KstConnectionState.DISCONNECTED,
+ On4KstConnectionState.RECONNECT_WAIT -> ConnectionBadge.OFFLINE
+ }
+
+ fun tooltipFor(state: On4KstConnectionState?, detail: String?): String {
+ val effective = state ?: On4KstConnectionState.DISCONNECTED
+ return "ON4KST link: ${effective.name}\n${detailFor(state, detail)}"
+ }
+
+ /**
+ * The detail line, as its own string.
+ *
+ * The tooltip carries it as a second line; the macOS menu carries it as its single
+ * disabled item, which is the same information reaching the operator by the other route.
+ */
+ fun detailFor(state: On4KstConnectionState?, detail: String?): String {
+ val effective = state ?: On4KstConnectionState.DISCONNECTED
+ return if (detail.isNullOrBlank()) effective.name else detail
+ }
+
+ /**
+ * Title of the macOS connection state menu, e.g. "🟢 LINK: Connected".
+ *
+ * Finer than the badge: in the menu bar there is room to distinguish shutting down
+ * from connecting, and a reconnect from a dead link.
+ */
+ fun macOsMenuTitle(state: On4KstConnectionState?): String =
+ when (state ?: On4KstConnectionState.DISCONNECTED) {
+ On4KstConnectionState.ONLINE -> "🟢 LINK: Connected"
+ On4KstConnectionState.CONNECTING,
+ On4KstConnectionState.WAITING_FOR_LOGIN_PROMPT,
+ On4KstConnectionState.AUTHENTICATING,
+ On4KstConnectionState.SYNCING_MAIN_CHAT,
+ On4KstConnectionState.SYNCING_SECOND_CHAT -> "🟡 LINK: Connecting…"
+ On4KstConnectionState.STOPPING -> "🟡 LINK: Disconnecting…"
+ On4KstConnectionState.RECONNECT_WAIT -> "🔴 LINK: Reconnecting…"
+ On4KstConnectionState.DISCONNECTED -> "🔴 LINK: Disconnected"
+ }
+}
+
+/**
+ * A non-clickable notice that blinks for a while and then disappears.
+ *
+ * Both the sked reminder and the band upgrade hint work this way. They are not
+ * dialogs on purpose — a dialog steals the keyboard, and the operator is typing.
+ *
+ * The blink itself belongs to the view; this holds what to draw and a revision the
+ * view restarts the blink on, so a second reminder blinks again instead of sitting
+ * there statically.
+ */
+class BlinkingNotice {
+
+ var text: String by mutableStateOf("")
+ private set
+
+ var tooltip: String by mutableStateOf("")
+ private set
+
+ var visible: Boolean by mutableStateOf(false)
+ private set
+
+ var revision: Int by mutableStateOf(0)
+ private set
+
+ fun show(text: String, tooltip: String = text) {
+ this.text = shorten(text)
+ this.tooltip = tooltip
+ visible = true
+ revision++
+ }
+
+ fun hide() {
+ visible = false
+ }
+
+ /**
+ * Hides the notice only if it is still the one that [generation] showed.
+ *
+ * The blink runs in the view and is cancelled if the bar leaves the composition — a
+ * splitter dragged shut, a profile switch — so the cleanup has to hide the notice, or
+ * a magenta reminder stays in the status bar for the rest of the session. But a second
+ * reminder arriving mid blink means the first blink's cleanup runs after the second is
+ * already showing, and it must not take that one down with it.
+ */
+ fun hide(generation: Int) {
+ if (revision == generation) {
+ visible = false
+ }
+ }
+
+ companion object {
+ /** The badge shares a row with the menu, so the text is cut and the tooltip keeps it. */
+ const val MAX_LABEL_LENGTH = 38
+ const val BLINK_CYCLES = 24
+ const val BLINK_CYCLE_MILLIS = 500L
+
+ val blinkDurationMillis: Long get() = BLINK_CYCLES * BLINK_CYCLE_MILLIS
+
+ fun shorten(text: String): String =
+ if (text.length > MAX_LABEL_LENGTH) text.take(MAX_LABEL_LENGTH - 3) + "..." else text
+ }
+}
+
+/** What makes a notice appear. */
+object StatusIndicators {
+
+ /** The wording the operator has learned to recognize; the double space is deliberate. */
+ fun reminderText(callSignRaw: String?, minutesBefore: Int): String =
+ "REMINDER: ${callSignRaw.orEmpty()} T-${minutesBefore}m"
+
+ /**
+ * The band upgrade hint rides on the ordinary thread status messages, recognized by
+ * name. Returns the badge text and the tooltip, or null if this message is about
+ * something else.
+ */
+ fun bandUpgradeNoticeFor(key: String?, message: ThreadStateMessage?): Pair? {
+ if (message == null) return null
+
+ val nick = message.threadNickName?.lowercase(Locale.ROOT).orEmpty()
+ val lowerKey = key?.lowercase(Locale.ROOT).orEmpty()
+ if (!lowerKey.contains("bandupgrade") && !nick.contains("bandupgrade")) return null
+
+ // ThreadStateMessage has its own fallback, so this rarely fires; it is here so a
+ // blank badge can never reach the status bar.
+ val badge = message.runningInformationTextDescription
+ ?.takeUnless { it.isBlank() } ?: "BAND+"
+ val tooltip = message.runningInformation?.takeUnless { it.isBlank() } ?: badge
+ return badge to tooltip
+ }
+}
+
+/**
+ * One button per background thread that reports itself, in the order they first did.
+ *
+ * The flash on every message is the point: it is how the operator sees a worker is
+ * still alive without reading anything.
+ */
+class ThreadStatusButtons {
+
+ private val bySource = LinkedHashMap()
+ private val ordered = mutableStateListOf()
+
+ val buttons: List get() = ordered
+
+ fun update(sourceName: String, message: ThreadStateMessage) {
+ val button = bySource.getOrPut(sourceName) {
+ ThreadStatusButton(sourceName).also { ordered.add(it) }
+ }
+ button.report(
+ label = "$sourceName: ${message.runningInformationTextDescription}",
+ tooltip = message.runningInformation.orEmpty(),
+ )
+ }
+
+ companion object {
+ /** How long the flash stays on; JavaFX used a 0.2 s PauseTransition. */
+ const val HIGHLIGHT_MILLIS = 200L
+ }
+}
+
+/** One background thread's button. */
+class ThreadStatusButton(val sourceName: String) {
+
+ var label: String by mutableStateOf(sourceName)
+ private set
+
+ var tooltip: String by mutableStateOf("")
+ private set
+
+ /** True while the flash is on; the view clears it after HIGHLIGHT_MILLIS. */
+ var highlighted: Boolean by mutableStateOf(false)
+ private set
+
+ var revision: Int by mutableStateOf(0)
+ private set
+
+ internal fun report(label: String, tooltip: String) {
+ this.label = label
+ this.tooltip = tooltip
+ highlighted = true
+ revision++
+ }
+
+ fun clearHighlight() {
+ highlighted = false
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/Theme.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/Theme.kt
new file mode 100644
index 00000000..f93d8072
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/Theme.kt
@@ -0,0 +1,157 @@
+package kst4contest.view.compose
+
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Typography
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.staticCompositionLocalOf
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.unit.sp
+
+/**
+ * The application theme for Compose windows.
+ *
+ * Release 1.50 gave the JavaFX side one base font size per operator profile.
+ * Compose windows take the same value so a Compose window next to a JavaFX one
+ * does not look scaled differently.
+ *
+ * @param darkMode the operator's configured day/evening design
+ * @param baseFontSizeSp the configured base font size, in scale-independent points
+ */
+@Composable
+fun Kst4ContestTheme(
+ darkMode: Boolean,
+ baseFontSizeSp: Float = 0f,
+ content: @Composable () -> Unit,
+) {
+ val palette = remember(darkMode) {
+ JavaFxStylesheet.read(
+ if (darkMode) EVENING_STYLESHEET else DAYLIGHT_STYLESHEET
+ )
+ }
+
+ val fontSize = if (baseFontSizeSp > 0f) baseFontSizeSp else palette.fontSizePx
+ val typography = MaterialTheme.typography.scaledTo(fontSize)
+
+ MaterialTheme(
+ colorScheme = palette.toColorScheme(darkMode),
+ typography = typography,
+ ) {
+ CompositionLocalProvider(LocalJavaFxPalette provides palette, content = content)
+ }
+}
+
+/** The two sheets JavaFX loads, by the names `applyThemeStylesheet` passes on. */
+private const val DAYLIGHT_STYLESHEET = "/KST4ContestDefaultDay.css"
+private const val EVENING_STYLESHEET = "/KST4ContestDefaultEvening.css"
+
+/**
+ * The palette in force, for the few places that need a colour Material has no slot
+ * for — the button hover gradient and its pressed border.
+ */
+val LocalJavaFxPalette = staticCompositionLocalOf {
+ JavaFxStylesheet.read(DAYLIGHT_STYLESHEET)
+}
+
+/**
+ * Maps the stylesheet onto Material's colour roles.
+ *
+ * Only the roles the settings windows actually paint with are taken from the sheet;
+ * the rest keep Material's defaults, which nothing in these windows shows.
+ */
+private fun JavaFxPalette.toColorScheme(darkMode: Boolean): ColorScheme {
+ val defaults = if (darkMode) darkColorScheme() else lightColorScheme()
+
+ /*
+ * The tint behind a selected row. Mixing the accent into the base keeps it legible
+ * in both designs, where a fixed colour would disappear into one of them.
+ */
+ val selection = blend(accent, base, 0.30f)
+
+ /*
+ * The sheet's own separator colour is #3C3C3C on a #373E43 surface — a brightness
+ * difference of 0.002, which is no line at all. Where the declared colour cannot be
+ * told apart from the surface it is replaced by a step away from that surface, so
+ * the section rules are actually visible. Making them visible is the point: they
+ * were asked for because a bare line of text did not read as a division.
+ */
+ val declaredLine = separatorLine
+ val contrast = brightness(declaredLine) - brightness(windowBackground)
+ val visibleLine = if (contrast > -0.08f && contrast < 0.08f) {
+ JavaFxStylesheet.derive(windowBackground, if (darkMode) 45.0 else -25.0)
+ } else {
+ declaredLine
+ }
+
+ return defaults.copy(
+ /*
+ * The green of .text-field .text, not -fx-accent: the blue accent only paints
+ * JavaFX's selection bar, while the green is what the operator sees on column
+ * headers and inside every field, and therefore what reads as the accent.
+ */
+ primary = textAccent,
+ onPrimary = contrastingText(textAccent),
+ background = windowBackground,
+ onBackground = labelTextFill,
+ surface = windowBackground,
+ onSurface = labelTextFill,
+ /* The interior of a text field: JavaFX sets -fx-control-inner-background. */
+ surfaceVariant = controlInnerBackground,
+ onSurfaceVariant = labelTextFill,
+ secondaryContainer = selection,
+ onSecondaryContainer = labelTextFill,
+ outline = visibleLine,
+ outlineVariant = visibleLine,
+ )
+}
+
+/** The brightness JavaFX measures with: 0.3R + 0.59G + 0.11B. */
+private fun brightness(color: Color): Float =
+ 0.3f * color.red + 0.59f * color.green + 0.11f * color.blue
+
+/** [amount] of [foreground] over [background]. */
+private fun blend(foreground: Color, background: Color, amount: Float): Color = Color(
+ red = foreground.red * amount + background.red * (1f - amount),
+ green = foreground.green * amount + background.green * (1f - amount),
+ blue = foreground.blue * amount + background.blue * (1f - amount),
+ alpha = 1f,
+)
+
+/**
+ * Black or white, whichever reads on the given colour. Uses the same brightness
+ * measure JavaFX does, so the decision matches what the rest of the application
+ * would make.
+ */
+private fun contrastingText(background: Color): Color =
+ if (brightness(background) > 0.5f) Color.Black else Color.White
+
+/**
+ * Scales every text style by the factor that turns the body size into
+ * [targetBodySizeSp]. A value of zero or less is ignored, so a missing or
+ * malformed configured size keeps the default design rather than collapsing it.
+ */
+internal fun Typography.scaledTo(targetBodySizeSp: Float): Typography {
+ if (targetBodySizeSp <= 0f) {
+ return this
+ }
+ val factor = targetBodySizeSp / bodyMedium.fontSize.value
+ if (factor == 1f) {
+ return this
+ }
+ fun TextStyle.scaled() = copy(fontSize = fontSize.value.times(factor).sp)
+ return copy(
+ displayLarge = displayLarge.scaled(), displayMedium = displayMedium.scaled(),
+ displaySmall = displaySmall.scaled(), headlineLarge = headlineLarge.scaled(),
+ headlineMedium = headlineMedium.scaled(), headlineSmall = headlineSmall.scaled(),
+ titleLarge = titleLarge.scaled(), titleMedium = titleMedium.scaled(),
+ titleSmall = titleSmall.scaled(), bodyLarge = bodyLarge.scaled(),
+ bodyMedium = bodyMedium.scaled(), bodySmall = bodySmall.scaled(),
+ labelLarge = labelLarge.scaled(), labelMedium = labelMedium.scaled(),
+ labelSmall = labelSmall.scaled(),
+ )
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/TimelineState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/TimelineState.kt
new file mode 100644
index 00000000..1746fd66
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/TimelineState.kt
@@ -0,0 +1,212 @@
+package kst4contest.view.compose
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import kst4contest.model.ChatCategory
+import kst4contest.model.ContestSked
+import kotlin.math.abs
+
+/**
+ * How promising an aircraft reflection is, in the four steps the timeline distinguishes.
+ *
+ * The colours are the original ones and they are not theme colours: this strip is read
+ * like a radar screen, where magenta means "now or never" in both designs.
+ */
+enum class PotentialLevel {
+ HIGHEST,
+ HIGH,
+ MEDIUM,
+ LOW;
+
+ companion object {
+ fun forPercent(percent: Int): PotentialLevel = when {
+ percent >= 95 -> HIGHEST
+ percent >= 75 -> HIGH
+ percent >= 50 -> MEDIUM
+ else -> LOW
+ }
+ }
+}
+
+/**
+ * One priority candidate on the timeline: a station worth calling at a given minute,
+ * placed on the next minute an aeroplane is expected.
+ *
+ * The Kotlin counterpart of TimelineView.CandidateEvent, built from the score service.
+ */
+data class TimelineCandidate(
+ val callSignRaw: String,
+ val displayCallSign: String,
+ val preferredChatCategory: ChatCategory?,
+ val timeUntilMs: Long,
+ val minuteBucket: Int,
+ val laneIndex: Int,
+ val targetAzimuth: Double,
+ val score: Double,
+ val opportunityPotentialPercent: Int,
+ val tooltipText: String?,
+)
+
+/**
+ * Where things sit on the strip.
+ *
+ * Time runs left to right as it *increases*, so "now" is at the left edge and the end
+ * of the preview window at the right. Imminent events are therefore on the left, which
+ * is the opposite of a progress bar and worth knowing before reading the numbers.
+ */
+object TimelineGeometry {
+
+ /** How far ahead the strip looks. */
+ const val PREVIEW_MILLIS: Long = 30L * 60L * 1000L
+
+ /**
+ * The height the strip is drawn into, and the height the JavaFX call site actually
+ * gave it. The class itself claimed 40 and then only ever used the upper half, which
+ * is why the candidate labels and the sked labels landed on top of each other. The
+ * lanes below are laid out for the real height.
+ */
+ const val HEIGHT: Float = 80f
+
+ /** The time axis, with the candidate lanes above it and the skeds below. */
+ const val AXIS_Y: Float = 44f
+
+ /** Candidates keep further from the edges than skeds do, because their labels are wider. */
+ const val CANDIDATE_MARGIN: Float = 30f
+ const val SKED_MARGIN: Float = 10f
+
+ const val LANE_BASE_Y: Float = 2f
+ const val LANE_HEIGHT: Float = 20f
+
+ /**
+ * How many candidate lanes fit above the axis. The producer keeps at most two
+ * candidates per minute, so this is a guard rather than a limit in practice: a third
+ * lane shares the second one instead of being drawn across the axis.
+ */
+ const val LANE_COUNT: Int = 2
+
+ /** Skeds get their own lane below the axis, which is what frees the labels. */
+ const val SKED_Y: Float = 52f
+
+ const val DEFAULT_BEAM_WIDTH_DEG: Double = 50.0
+
+ /**
+ * The x for an event that many milliseconds away, or null when it falls outside the
+ * preview window and is not drawn at all.
+ */
+ fun xFor(timeUntilMs: Long, width: Float, margin: Float): Float? {
+ if (timeUntilMs < 0 || timeUntilMs > PREVIEW_MILLIS) return null
+
+ val x = timeUntilMs.toFloat() / PREVIEW_MILLIS.toFloat() * width
+
+ // A strip narrower than two margins leaves no room to hold the marker off both
+ // edges, and coerceIn refuses an empty range rather than picking a side. Centring
+ // is the honest answer: on a strip that narrow the position carries no information
+ // anyway, and this window lives on a tiling compositor where that happens.
+ val lowerBound = margin
+ val upperBound = width - margin
+ if (lowerBound > upperBound) return width / 2f
+
+ return x.coerceIn(lowerBound, upperBound)
+ }
+
+ /**
+ * Where a label starts, given how wide it turned out. It sits to the right of its
+ * marker, and flips to the left rather than running off the strip — near the right
+ * edge, which is half an hour away, there is always room on the other side.
+ */
+ fun labelStartX(markerX: Float, labelWidth: Float, stripWidth: Float, gap: Float): Float {
+ val toTheRight = markerX + gap
+ return if (toTheRight + labelWidth <= stripWidth) toTheRight
+ else (markerX - gap - labelWidth).coerceAtLeast(0f)
+ }
+
+ fun laneY(laneIndex: Int): Float =
+ LANE_BASE_Y + LANE_HEIGHT * laneIndex.coerceIn(0, LANE_COUNT - 1)
+}
+
+/**
+ * How strongly a marker is drawn, given where the antenna points.
+ *
+ * Off-beam markers fade rather than disappear: the operator has to be able to see that
+ * a chance exists in a direction they are not pointing, otherwise turning the rotator
+ * would never occur to them.
+ */
+data class AntennaEmphasis(
+ val iconAlpha: Float,
+ val glowing: Boolean,
+ val scale: Float,
+) {
+ companion object {
+ /** Full strength, no glow — also what an unknown azimuth gets, so it stays readable. */
+ val PLAIN = AntennaEmphasis(iconAlpha = 1f, glowing = false, scale = 1f)
+
+ const val OFF_BEAM_ALPHA = 0.30f
+ const val ON_TARGET_SCALE = 1.10f
+
+ /**
+ * Within half a beam width the marker glows and grows; out to a full beam width
+ * it stays bright; beyond that it fades to a ghost.
+ */
+ fun forAzimuth(
+ currentAzimuth: Double,
+ targetAzimuth: Double,
+ beamWidthDeg: Double,
+ ): AntennaEmphasis {
+ // An unknown or negative azimuth is not a direction, so nothing is implied by it.
+ if (!targetAzimuth.isFinite() || targetAzimuth < 0) return PLAIN
+
+ var delta = abs(currentAzimuth - targetAzimuth)
+ if (delta > 180) delta = 360 - delta
+
+ val onTarget = delta <= beamWidthDeg / 2.0
+ val inBeam = delta <= beamWidthDeg
+
+ return AntennaEmphasis(
+ iconAlpha = if (inBeam) 1f else OFF_BEAM_ALPHA,
+ glowing = onTarget,
+ scale = if (onTarget) ON_TARGET_SCALE else 1f,
+ )
+ }
+ }
+}
+
+/**
+ * What the timeline draws.
+ *
+ * Compose state throughout: the strip is redrawn on the score service's pulse, on every
+ * rotator movement and on every resize, and each of those has to reach the screen on
+ * its own.
+ */
+class TimelineState {
+
+ val skeds = mutableStateListOf()
+ val candidates = mutableStateListOf()
+
+ /** Where the antenna points now, from the rotator or the manually set QTF. */
+ var antennaAzimuth: Double by mutableStateOf(0.0)
+
+ var beamWidthDeg: Double by mutableStateOf(TimelineGeometry.DEFAULT_BEAM_WIDTH_DEG)
+ private set
+
+ /** A station without a sensible beam width keeps the default rather than a nonsense one. */
+ fun setBeamWidth(degrees: Double) {
+ if (degrees > 0 && degrees.isFinite()) {
+ beamWidthDeg = degrees
+ }
+ }
+
+ fun replaceSkeds(newSkeds: List) {
+ skeds.clear()
+ skeds.addAll(newSkeds)
+ }
+
+ fun replaceCandidates(newCandidates: List) {
+ candidates.clear()
+ candidates.addAll(newCandidates)
+ }
+
+ fun emphasisFor(targetAzimuth: Double): AntennaEmphasis =
+ AntennaEmphasis.forAzimuth(antennaAzimuth, targetAzimuth, beamWidthDeg)
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/TimelineView.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/TimelineView.kt
new file mode 100644
index 00000000..b2a66095
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/TimelineView.kt
@@ -0,0 +1,311 @@
+package kst4contest.view.compose
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.TooltipArea
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.offset
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.drawscope.scale
+
+import androidx.compose.foundation.onClick
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.rememberTextMeasurer
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import kst4contest.model.ContestSked
+import kotlin.math.roundToInt
+
+/** The strip is read like a radar screen, so it stays dark in both designs. */
+private val STRIP_BACKGROUND = Color(0xFF2B2B2B)
+private val AXIS_COLOR = Color.Gray
+
+/** The glow that marks a target the antenna is centred on. */
+private val GLOW_COLOR = Color(0xFF32CD32)
+
+private val MARKER_SIZE = 12.dp
+private val LABEL_GAP = 4.dp
+private val CHIP_SHAPE = RoundedCornerShape(6.dp)
+private val CHIP_BACKGROUND = Color(0xA6000000)
+
+/**
+ * The strip above the send field: what is coming in the next half hour.
+ *
+ * The one view in this client that is read as a picture rather than as text. Skeds and
+ * priority candidates are placed by time, coloured by how promising the aircraft
+ * reflection is, and faded when the antenna points elsewhere — so one glance answers
+ * both "what is next" and "could I even hear it right now".
+ *
+ * Time grows to the right, which means the imminent things are on the LEFT. That is the
+ * opposite of a progress bar and it is the original's choice, kept because operators
+ * have learned it.
+ *
+ * Markers are drawn rather than built from widgets: this is a diagram, with no rows and
+ * nothing to select. Only the candidates answer a click.
+ */
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun TimelineView(
+ state: TimelineState,
+ onCandidateClicked: (TimelineCandidate) -> Unit,
+ skedTooltipExtra: (ContestSked) -> String? = { null },
+ modifier: Modifier = Modifier,
+) {
+ val nowMillis = androidx.compose.runtime.mutableStateOf(System.currentTimeMillis())
+
+ androidx.compose.runtime.LaunchedEffect(Unit) {
+ while (true) {
+ kotlinx.coroutines.delay(1000)
+ nowMillis.value = System.currentTimeMillis()
+ }
+ }
+
+ val density = LocalDensity.current
+ val measurer = rememberTextMeasurer()
+
+ BoxWithConstraints(
+ modifier
+ .height(TimelineGeometry.HEIGHT.dp)
+ .background(STRIP_BACKGROUND),
+ ) {
+ val widthPx = with(density) { maxWidth.toPx() }
+ val axisY = with(density) { TimelineGeometry.AXIS_Y.dp.toPx() }
+ val candidateMargin = with(density) { TimelineGeometry.CANDIDATE_MARGIN.dp.toPx() }
+ val skedMargin = with(density) { TimelineGeometry.SKED_MARGIN.dp.toPx() }
+
+ Canvas(Modifier.fillMaxSize()) {
+ drawLine(AXIS_COLOR, Offset(0f, axisY), Offset(size.width, axisY))
+ }
+
+ // The candidate lanes, above the axis.
+ state.candidates.forEach { candidate ->
+ val x = TimelineGeometry.xFor(candidate.timeUntilMs, widthPx, candidateMargin)
+ ?: return@forEach
+
+ TimelineMarker(
+ centerXPx = x,
+ topDp = TimelineGeometry.laneY(candidate.laneIndex).dp,
+ stripWidthPx = widthPx,
+ label = candidate.displayCallSign,
+ tooltip = candidate.tooltipText,
+ level = PotentialLevel.forPercent(candidate.opportunityPotentialPercent),
+ emphasis = state.emphasisFor(candidate.targetAzimuth),
+ shape = MarkerShape.CANDIDATE,
+ labelWidthPx = { text -> measurer.measure(text, CHIP_TEXT_STYLE).size.width.toFloat() },
+ onClick = { onCandidateClicked(candidate) },
+ )
+ }
+
+ // The skeds, in their own lane below the axis. In JavaFX they shared the space
+ // above it, which is why their labels sat on top of the candidates'.
+ val now = nowMillis.value
+ state.skeds.forEach { sked ->
+ val x = TimelineGeometry.xFor(sked.skedTimeEpoch - now, widthPx, skedMargin)
+ ?: return@forEach
+
+ TimelineMarker(
+ centerXPx = x,
+ topDp = TimelineGeometry.SKED_Y.dp,
+ stripWidthPx = widthPx,
+ label = "SKED: ${sked.targetChatCallsign}",
+ tooltip = skedTooltip(sked, skedTooltipExtra),
+ level = PotentialLevel.forPercent(sked.opportunityPotentialPercent),
+ emphasis = state.emphasisFor(sked.targetAzimuth),
+ shape = MarkerShape.SKED,
+ labelWidthPx = { text -> measurer.measure(text, CHIP_TEXT_STYLE).size.width.toFloat() },
+ onClick = null,
+ )
+ }
+ }
+}
+
+/**
+ * The sked tooltip: who, on what band, in which direction — plus whatever the caller can
+ * add about the chance itself.
+ */
+private fun skedTooltip(sked: ContestSked, extraProvider: (ContestSked) -> String?): String {
+ val base = "${sked.targetChatCallsign} (${sked.band})\nAz: ${sked.targetAzimuth}"
+ val extra = extraProvider(sked)
+ return if (extra.isNullOrBlank()) base else "$base\n$extra"
+}
+
+private enum class MarkerShape { SKED, CANDIDATE }
+
+private val CHIP_TEXT_STYLE = TextStyle(
+ color = Color.White,
+ fontSize = 9.sp,
+ fontWeight = FontWeight.Bold,
+)
+
+/**
+ * One marker: the symbol on the time axis and its callsign beside it.
+ *
+ * The label is never faded, only the symbol is. A ghost marker still has to be
+ * readable — that is the whole point of showing a chance in a direction the antenna is
+ * not pointing.
+ */
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun TimelineMarker(
+ centerXPx: Float,
+ topDp: Dp,
+ stripWidthPx: Float,
+ label: String,
+ tooltip: String?,
+ level: PotentialLevel,
+ emphasis: AntennaEmphasis,
+ shape: MarkerShape,
+ labelWidthPx: (String) -> Float,
+ onClick: (() -> Unit)?,
+) {
+ val density = LocalDensity.current
+ val markerSizePx = with(density) { MARKER_SIZE.toPx() }
+ val gapPx = with(density) { LABEL_GAP.toPx() }
+
+ val labelWidth = labelWidthPx(label)
+ val labelX = TimelineGeometry.labelStartX(
+ markerX = centerXPx + markerSizePx / 2f,
+ labelWidth = labelWidth,
+ stripWidth = stripWidthPx,
+ gap = gapPx,
+ )
+
+ val symbol: @Composable () -> Unit = {
+ Canvas(
+ Modifier
+ .size(MARKER_SIZE)
+ .alpha(emphasis.iconAlpha),
+ ) {
+ scale(emphasis.scale) {
+ if (emphasis.glowing) drawGlow()
+ when (shape) {
+ MarkerShape.SKED -> drawDiamond(markerColor(level))
+ MarkerShape.CANDIDATE -> drawCandidate(markerColor(level))
+ }
+ }
+ }
+ }
+
+ Box(
+ Modifier.offset {
+ IntOffset(
+ (centerXPx - markerSizePx / 2f).roundToInt(),
+ with(density) { topDp.toPx() }.roundToInt(),
+ )
+ },
+ ) {
+ val clickable = if (onClick != null) Modifier.onClick { onClick() } else Modifier
+ if (tooltip.isNullOrBlank()) {
+ Box(clickable) { symbol() }
+ } else {
+ TooltipArea(tooltip = { TimelineTooltip(tooltip) }) {
+ Box(clickable) { symbol() }
+ }
+ }
+ }
+
+ // The label is placed on its own so it can flip sides without moving the symbol.
+ Box(
+ Modifier.offset {
+ IntOffset(
+ labelX.roundToInt(),
+ with(density) { topDp.toPx() }.roundToInt(),
+ )
+ },
+ ) {
+ Box(
+ Modifier
+ .background(CHIP_BACKGROUND, CHIP_SHAPE)
+ .padding(horizontal = 4.dp, vertical = 1.dp),
+ ) {
+ Text(text = label, style = CHIP_TEXT_STYLE, maxLines = 1)
+ }
+ }
+}
+
+/** Magenta means now or never; deep sky blue means it is barely worth turning for. */
+private fun markerColor(level: PotentialLevel): Color = when (level) {
+ PotentialLevel.HIGHEST -> Color.Magenta
+ PotentialLevel.HIGH -> Color.Red
+ PotentialLevel.MEDIUM -> Color.Yellow
+ PotentialLevel.LOW -> Color(0xFF00BFFF)
+}
+
+/** The diamond of a sked: an appointment, not a chance. */
+private fun DrawScope.drawDiamond(color: Color) {
+ val w = size.width
+ val h = size.height
+ val path = Path().apply {
+ moveTo(w / 2f, 0f)
+ lineTo(w, h / 2f)
+ lineTo(w / 2f, h)
+ lineTo(0f, h / 2f)
+ close()
+ }
+ drawPath(path, color)
+}
+
+/** A candidate: a downward triangle over a dot, so it reads even at this size. */
+private fun DrawScope.drawCandidate(color: Color) {
+ val w = size.width
+ val h = size.height
+ drawCircle(color, radius = w / 3f, center = Offset(w / 2f, h / 2f))
+ val path = Path().apply {
+ moveTo(0f, 0f)
+ lineTo(w, 0f)
+ lineTo(w / 2f, h * 0.85f)
+ close()
+ }
+ drawPath(path, color)
+}
+
+/**
+ * The glow for a target the antenna is centred on. JavaFX used a drop shadow; here it is
+ * three fading rings, which costs nothing and reads the same at this size.
+ */
+private fun DrawScope.drawGlow() {
+ val center = Offset(size.width / 2f, size.height / 2f)
+ listOf(1.6f to 0.10f, 1.3f to 0.18f, 1.05f to 0.28f).forEach { (factor, alpha) ->
+ drawCircle(
+ color = GLOW_COLOR.copy(alpha = alpha),
+ radius = size.width / 2f * factor,
+ center = center,
+ )
+ }
+}
+
+@Composable
+private fun TimelineTooltip(text: String) {
+ Surface(
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
+ shape = Density.BUTTON_SHAPE,
+ tonalElevation = 4.dp,
+ ) {
+ Text(
+ text = text,
+ modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/TopPriorityState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/TopPriorityState.kt
new file mode 100644
index 00000000..1b556a09
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/TopPriorityState.kt
@@ -0,0 +1,44 @@
+package kst4contest.view.compose
+
+import androidx.compose.runtime.mutableStateOf
+import kst4contest.model.ChatMember
+
+/** One priority button: what it says and what pressing it does. */
+class TopPriorityEntry(val label: String, val select: () -> Unit)
+
+/**
+ * The two priority buttons beside the station list.
+ *
+ * A shortcut to the station the score says to work next, so what they name and what
+ * they do when pressed is the whole of their value. Everything below the first two is
+ * behind the "more" button, as it was.
+ *
+ * @param topStations the ranked stations with their scores, highest first
+ * @param onSelect selects a station in the list, which is what pressing a button did
+ */
+class TopPriorityState(
+ private val topStations: () -> List>,
+ private val onSelect: (ChatMember) -> Unit,
+) {
+
+ private val _entries = mutableStateOf>(emptyList())
+
+ val entries: List
+ get() = _entries.value
+
+ fun refresh() {
+ _entries.value = topStations()
+ .filter { (member, _) -> !member.callSign.isNullOrBlank() }
+ .take(SHOWN)
+ .mapIndexed { index, (member, score) ->
+ TopPriorityEntry(
+ label = "${index + 1} ${member.callSign} ${score.toInt()}",
+ ) { onSelect(member) }
+ }
+ }
+
+ private companion object {
+ /** Two, as the JavaFX pane had; the rest sit behind "more". */
+ const val SHOWN = 2
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/UpdateWindow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/UpdateWindow.kt
new file mode 100644
index 00000000..a76ab89a
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/UpdateWindow.kt
@@ -0,0 +1,153 @@
+package kst4contest.view.compose
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.unit.dp
+import java.util.function.Consumer
+
+/**
+ * The update window.
+ *
+ * Shown at startup and only when there is an update; [UpdateWindowState.updateAvailable]
+ * decides that, and it is tested. Non-blocking like the settings window: the operator
+ * reads it when they get round to it.
+ */
+object UpdateWindow {
+
+ private val host = ComposeWindowHost("update-window")
+
+ @JvmStatic
+ val isOpen: Boolean
+ get() = host.isOpen
+
+ @JvmStatic
+ fun applyDarkMode(darkMode: Boolean) = host.applyDarkMode(darkMode)
+
+ @JvmStatic
+ fun close() = host.close()
+
+ /**
+ * @param openReleasePage opens the given address in the system browser; passed in
+ * because reaching the browser is the host application's business. A
+ * Consumer rather than a Kotlin function type: this is called from Java, and
+ * a Kotlin lambda parameter would force the caller to return Unit by hand.
+ */
+ @JvmStatic
+ fun show(
+ state: UpdateWindowState,
+ darkMode: Boolean,
+ baseFontSizeSp: Float,
+ widthDp: Float,
+ heightDp: Float,
+ openReleasePage: Consumer,
+ onResized: (Float, Float) -> Unit,
+ ) {
+ host.show(
+ title = "Update information",
+ darkMode = darkMode,
+ baseFontSizeSp = baseFontSizeSp,
+ widthDp = widthDp,
+ heightDp = heightDp,
+ onResized = onResized,
+ alwaysOnTop = true,
+ ) { _ ->
+ UpdateContent(state, openReleasePage::accept)
+ }
+ }
+}
+
+@Composable
+private fun UpdateContent(state: UpdateWindowState, openReleasePage: (String) -> Unit) {
+ Surface(modifier = Modifier.fillMaxSize()) {
+ Column(
+ modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
+ ) {
+ Text("Update available!", style = MaterialTheme.typography.titleMedium)
+
+ Form.section("Versions") {
+ Form.labelledRow("Installed version:") { Text(state.installedVersion) }
+ Form.labelledRow("Latest stable version:") { Text(state.latestVersion) }
+ }
+
+ Form.section("Main changes:") { Text(state.majorChanges) }
+
+ Form.section("Additional information:") { Text(state.adminMessage) }
+
+ Form.section("Download:") {
+ Text(
+ "Open release page",
+ color = MaterialTheme.colorScheme.primary,
+ textDecoration = TextDecoration.Underline,
+ modifier = Modifier.clickable { openReleasePage(state.releasePageUrl) },
+ )
+ }
+
+ /*
+ * The JavaFX window drew these as a TreeView with a hidden root. The depth
+ * is fixed at two — a heading and its lines — so two expandable sections do
+ * the same job without a tree component.
+ */
+ UpdateBranch("ChangeLog", state.changeLog)
+ UpdateBranch("Known bugs", state.knownBugs)
+ }
+ }
+}
+
+/** One branch of the former tree: a heading, and sections that fold open. */
+@Composable
+private fun UpdateBranch(title: String, sections: List) {
+ if (sections.isEmpty()) {
+ return
+ }
+
+ Form.section(title) {
+ sections.forEach { section ->
+ var expanded by remember(section.title) { mutableStateOf(false) }
+
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { expanded = !expanded }
+ .padding(vertical = 2.dp),
+ horizontalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ Text(if (expanded) "▾" else "▸")
+ Text(section.title, fontWeight = FontWeight.Bold)
+ }
+
+ if (expanded) {
+ section.entries.forEach { entry ->
+ Text(
+ entry,
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(start = 20.dp, bottom = 1.dp),
+ )
+ }
+ }
+
+ HorizontalDivider(thickness = Density.HAIRLINE)
+ }
+ }
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/UpdateWindowState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/UpdateWindowState.kt
new file mode 100644
index 00000000..a674c1e3
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/UpdateWindowState.kt
@@ -0,0 +1,96 @@
+package kst4contest.view.compose
+
+import kst4contest.model.UpdateInformation
+import kst4contest.utils.VersionUtils
+
+/**
+ * One branch of the update window's tree: a heading and the lines beneath it.
+ *
+ * Both branches of that tree have the same shape. The change log and the bug list
+ * arrive as arrays whose first element is the heading and whose remaining elements are
+ * the lines, so one type serves both.
+ */
+data class UpdateSection(val title: String, val entries: List)
+
+/**
+ * What the update window shows, and whether it opens at all.
+ *
+ * Free of any toolkit so the decision can be tested. The decision is the interesting
+ * part: an update window that opens when there is no update is a daily annoyance, and
+ * one that stays shut when there is an update defeats the whole mechanism.
+ *
+ * @param currentVersion the running version, `ApplicationConstants.APPLICATION_CURRENT_VERSION`
+ * @param currentVersionNumber the running version as a number, for the older
+ * comparison that is still used when the server reports no semantic version
+ */
+class UpdateWindowState(
+ private val updateInformation: UpdateInformation?,
+ private val currentVersion: String,
+ private val currentVersionNumber: Double,
+) {
+
+ /**
+ * Whether an update exists.
+ *
+ * Two comparisons, as in the JavaFX window: a semantic one when the server reports
+ * a semantic version, and the older numeric one otherwise. Absent update
+ * information means no update — the check runs at startup, and a failed download
+ * must not stop the application from coming up.
+ */
+ val updateAvailable: Boolean
+ get() {
+ val information = updateInformation ?: return false
+
+ return if (information.hasSemanticVersion()) {
+ VersionUtils.compareStableVersions(
+ information.latestSemanticVersionOnServer,
+ currentVersion,
+ ) > 0
+ } else {
+ information.latestVersionNumberOnServer > currentVersionNumber
+ }
+ }
+
+ /** "KST4Contest 1.50.0", as the window prints it. */
+ val installedVersion: String
+ get() = DISPLAY_NAME + " " + currentVersion
+
+ val latestVersion: String
+ get() = DISPLAY_NAME + " " + (updateInformation?.latestVersionForDisplay ?: "")
+
+ val majorChanges: String
+ get() = updateInformation?.majorChanges ?: ""
+
+ val adminMessage: String
+ get() = updateInformation?.adminMessage ?: ""
+
+ /** Opened in the system browser by the "Open release page" link. */
+ val releasePageUrl: String
+ get() = updateInformation?.latestVersionPathOnWebserver ?: ""
+
+ val changeLog: List
+ get() = sectionsOf(updateInformation?.changeLog)
+
+ val knownBugs: List
+ get() = sectionsOf(updateInformation?.bugList)
+
+ /**
+ * Turns the stored arrays into sections.
+ *
+ * A row with nothing but a heading yields a section without lines rather than being
+ * dropped: the heading is the version, and a version that brought no listed change
+ * is still worth showing as released.
+ */
+ private fun sectionsOf(rows: List>?): List =
+ rows.orEmpty()
+ .filter { it.isNotEmpty() }
+ .map { row -> UpdateSection(row.first(), row.drop(1)) }
+
+ companion object {
+ /**
+ * The window prints "KST4Contest", not `APPLICATION_NAME`. That constant is
+ * "praktiKST" and names the application directory, not the product.
+ */
+ const val DISPLAY_NAME = "KST4Contest"
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/WindowShortcuts.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/WindowShortcuts.kt
new file mode 100644
index 00000000..24a82ef3
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/WindowShortcuts.kt
@@ -0,0 +1,70 @@
+package kst4contest.view.compose
+
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.input.key.KeyEvent
+import androidx.compose.ui.input.key.KeyEventType
+import androidx.compose.ui.input.key.isCtrlPressed
+import androidx.compose.ui.input.key.key
+import androidx.compose.ui.input.key.type
+import androidx.compose.ui.input.key.utf16CodePoint
+
+/**
+ * The keys that belong to the whole main window rather than to one control.
+ *
+ * JavaFX hung all of these on the Scene, and the tables even re-fired the send button from
+ * their own key filter. An operator who has just clicked a station, a message row or a button
+ * must still be able to press Enter and have the message go; and Ctrl+1…Ctrl+0 is how
+ * snippets are used at all, because the hands are already on the keyboard.
+ */
+object WindowShortcuts {
+
+ /**
+ * Which snippet a typed character selects, or null if it is not one of the ten digits.
+ *
+ * Ctrl+1…Ctrl+9 are slots 0 to 8 and Ctrl+0 is the tenth, because the digit row reads
+ * 1 to 9 and then 0.
+ */
+ fun snippetIndexFor(typed: String): Int? = when (typed) {
+ "1" -> 0
+ "2" -> 1
+ "3" -> 2
+ "4" -> 3
+ "5" -> 4
+ "6" -> 5
+ "7" -> 6
+ "8" -> 7
+ "9" -> 8
+ "0" -> 9
+ else -> null
+ }
+
+ /**
+ * Handles one key event for the window. Returns true when the event was consumed, which
+ * is what keeps Enter out of the text and stops the key travelling further.
+ *
+ * @param send what Enter does, wherever the focus is
+ * @param clear what Escape does
+ * @param insertSnippet what Ctrl+ does, with the slot it selected
+ */
+ fun handle(
+ event: KeyEvent,
+ send: () -> Unit,
+ clear: () -> Unit,
+ insertSnippet: (Int) -> Unit,
+ ): Boolean {
+ if (event.type != KeyEventType.KeyDown) return false
+
+ if (event.isCtrlPressed) {
+ val index = snippetIndexFor(event.utf16CodePoint.toChar().toString())
+ ?: return false
+ insertSnippet(index)
+ return true
+ }
+
+ return when (event.key) {
+ Key.Enter, Key.NumPadEnter -> { send(); true }
+ Key.Escape -> { clear(); true }
+ else -> false
+ }
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/map/ComposeStationMap.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/ComposeStationMap.kt
new file mode 100644
index 00000000..9e81f338
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/ComposeStationMap.kt
@@ -0,0 +1,916 @@
+package kst4contest.view.compose.map
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.gestures.detectTransformGestures
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clipToBounds
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.PathEffect
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.drawscope.Fill
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.graphics.toComposeImageBitmap
+import androidx.compose.ui.input.pointer.PointerEventType
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.text.TextLayoutResult
+import androidx.compose.ui.text.TextMeasurer
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.drawText
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.rememberTextMeasurer
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.IntSize
+import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.unit.TextUnitType
+import kst4contest.view.map.MaidenheadGridRenderPlanner
+import kst4contest.view.map.MaidenheadGridUtils
+import kst4contest.view.map.MapCallsignRawSnapshot
+import kst4contest.view.map.MapMarkerCluster
+import kst4contest.view.map.MapMarkerClusterTooltip
+import kst4contest.view.map.MapViewport
+import kst4contest.view.map.StationMapClusterer
+import kst4contest.view.map.TileFetcher
+import kotlinx.coroutines.flow.collectLatest
+import org.jetbrains.skia.Image
+import java.util.concurrent.ConcurrentHashMap
+import kotlin.math.ceil
+import kotlin.math.floor
+import kotlin.math.log2
+import kotlin.math.sign
+
+/**
+ * Where the map is looking. Compose state, so a pan or a zoom reaches the screen.
+ *
+ * Holds no canvas size: the size is whatever the canvas is measured at, and a
+ * [MapViewport] is built from the two together for each frame.
+ */
+class MapState(
+ zoom: Float = 6f,
+ centerLon: Double = 10.0,
+ centerLat: Double = 51.0,
+) {
+ var zoom by mutableStateOf(zoom.coerceIn(MapViewport.MIN_ZOOM.toFloat(), MapViewport.MAX_ZOOM.toFloat()))
+ var centerLon by mutableStateOf(centerLon)
+ var centerLat by mutableStateOf(centerLat)
+
+ fun viewportFor(widthPx: Float, heightPx: Float): MapViewport =
+ MapViewport(centerLon, centerLat, zoom.toDouble(), widthPx.toDouble(), heightPx.toDouble())
+
+ fun applyFrom(viewport: MapViewport) {
+ centerLon = viewport.centerLon()
+ centerLat = viewport.centerLat()
+ zoom = viewport.zoom().toFloat()
+ }
+}
+
+/**
+ * The station map as a Compose canvas.
+ *
+ * **Everything but the tiles is drawn in canvas pixels.** A station dot is 12 px wide at
+ * zoom 4 and at zoom 17; so is its label, and so is a grid line. Leaflet behaved that
+ * way because its overlays sat in the DOM above a scaled tile layer, and the sizes here
+ * are the ones its stylesheet declared. [MapViewport] makes that possible by folding the
+ * fractional zoom into the projection.
+ *
+ * The previous version instead wrapped part of the drawing in `scale(scaleFactor)` and
+ * divided every size back out by hand. The geometry that came out of it was right, but
+ * two things were not. Text was measured at a size divided by the zoom fraction and then
+ * magnified by it, so glyphs were rasterised at the wrong size. And the scale block
+ * closed before the beam, the path line and the home marker were drawn, which left those
+ * three using unscaled coordinates while the markers beside them used scaled ones — so at
+ * any zoom with a fraction, the antenna beam did not start where the map said home was.
+ * Neither failure is possible here, because nothing is scaled.
+ */
+@Composable
+fun ComposeStationMap(
+ markers: List,
+ tileFetcher: TileFetcher,
+ mapState: MapState,
+ groupingEnabled: Boolean,
+ selectedMarkerCallsign: String?,
+ ownLocator6: String,
+ antennaAzimuthDeg: Double,
+ antennaBeamWidthDeg: Double,
+ maxQrbKm: Double,
+ onMarkerClicked: ((MapCallsignRawSnapshot) -> Unit)?,
+ darkMode: Boolean = true,
+ modifier: Modifier = Modifier,
+) {
+ val imageCache = remember { TileBitmapCache() }
+ val fetchingTiles = remember { ConcurrentHashMap.newKeySet() }
+ val textMeasurer = rememberTextMeasurer()
+
+ var hoverPosition by remember { mutableStateOf(null) }
+
+ /*
+ * Measured, not read off the draw pass. Writing Compose state while drawing costs a
+ * frame at best: the first frame would group the stations against a zero-sized
+ * viewport and the second would correct it, which is visible as a flicker of
+ * wrongly grouped markers whenever the window is resized.
+ */
+ var canvasSize by remember { mutableStateOf(Size.Zero) }
+
+ /* Bumped when a tile arrives, which is what asks for the frame that shows it. */
+ var tileRevision by remember { mutableStateOf(0) }
+
+ /*
+ * Grouping depends on where the map is looking, so it cannot be cached on the
+ * stations alone.
+ *
+ * It is NOT off the drawing path: derivedStateOf computes lazily at first read, and
+ * that read is inside the Canvas lambda. What changed against the first port is the
+ * cost of the pass, not when it runs — that one searched for the nearest existing
+ * cluster for every station, which is quadratic; this one buckets by screen cell in
+ * one linear pass over a few hundred markers.
+ */
+ val home = remember(ownLocator6) { homePosition(ownLocator6) }
+ val clusterResult by remember(markers, groupingEnabled, canvasSize) {
+ derivedStateOf {
+ StationMapClusterer.cluster(
+ markers,
+ mapState.viewportFor(canvasSize.width, canvasSize.height),
+ groupingEnabled,
+ )
+ }
+ }
+
+ /*
+ * Fetching belongs here and not in the draw pass. A tile the fetcher already holds
+ * completes its future inline, so a fetch started while drawing would write the
+ * cache and ask for a redraw in the middle of the frame that needed it — and the
+ * frame would go out without it.
+ */
+ LaunchedEffect(tileFetcher) {
+ /*
+ * Read inside a snapshotFlow and not as effect keys. Keys are read during
+ * composition, so keying on the zoom and the centre would recompose this whole
+ * composable — and rebuild its gesture nodes — on every frame of a drag, when
+ * all the frame needs is a redraw.
+ */
+ snapshotFlow {
+ TileRequest(canvasSize, mapState.zoom, mapState.centerLon, mapState.centerLat)
+ }.collectLatest { request ->
+ if (request.size.width <= 0f || request.size.height <= 0f) {
+ return@collectLatest
+ }
+ val viewport = mapState.viewportFor(request.size.width, request.size.height)
+ for (key in visibleTileKeys(viewport)) {
+ if (imageCache.contains(key.cacheKey) || !fetchingTiles.add(key.cacheKey)) {
+ continue
+ }
+ tileFetcher.fetchTileAsync(key.zoom, key.x, key.y).whenComplete { bytes, _ ->
+ try {
+ if (bytes != null) {
+ imageCache.put(key.cacheKey, Image.makeFromEncoded(bytes).toComposeImageBitmap())
+ tileRevision++
+ }
+ } catch (undecodable: Exception) {
+ /*
+ * Bytes that will not decode are a truncated or corrupt cache
+ * entry, and serving them again on the next pan would blank that
+ * square for good. Thrown away so the tile is fetched afresh.
+ */
+ tileFetcher.discardTile(key.zoom, key.x, key.y)
+ } finally {
+ fetchingTiles.remove(key.cacheKey)
+ }
+ }
+ }
+ }
+ }
+
+ Canvas(
+ modifier = modifier
+ .clipToBounds()
+ .onSizeChanged { canvasSize = Size(it.width.toFloat(), it.height.toFloat()) }
+ .pointerInput(Unit) {
+ detectTransformGestures { centroid, pan, zoomChange, _ ->
+ var viewport = mapState.viewportFor(size.width.toFloat(), size.height.toFloat())
+ if (pan != Offset.Zero) {
+ viewport = viewport.pannedBy(pan.x.toDouble(), pan.y.toDouble())
+ }
+ if (zoomChange != 1f && zoomChange > 0f) {
+ viewport = viewport.zoomedTo(
+ viewport.zoom() + log2(zoomChange.toDouble()),
+ centroid.x.toDouble(),
+ centroid.y.toDouble(),
+ )
+ }
+ mapState.applyFrom(viewport)
+ }
+ }
+ .pointerInput(Unit) {
+ awaitPointerEventScope {
+ while (true) {
+ val event = awaitPointerEvent()
+ when (event.type) {
+ PointerEventType.Scroll -> {
+ val change = event.changes.first()
+ val scrollDelta = change.scrollDelta.y
+ if (scrollDelta != 0f) {
+ val viewport = mapState.viewportFor(size.width.toFloat(), size.height.toFloat())
+ mapState.applyFrom(
+ viewport.zoomedTo(
+ viewport.zoom() - scrollDelta.sign * ZOOM_STEP_PER_WHEEL_NOTCH,
+ change.position.x.toDouble(),
+ change.position.y.toDouble(),
+ )
+ )
+ }
+ }
+
+ PointerEventType.Move -> hoverPosition = event.changes.first().position
+ PointerEventType.Exit -> hoverPosition = null
+ }
+ }
+ }
+ }
+ .pointerInput(markers, groupingEnabled, onMarkerClicked) {
+ detectTapGestures { offset ->
+ val viewport = mapState.viewportFor(size.width.toFloat(), size.height.toFloat())
+ val result = StationMapClusterer.cluster(markers, viewport, groupingEnabled)
+
+ val hitCluster = result.clusters().firstOrNull { cluster ->
+ within(
+ offset,
+ viewport.screenX(cluster.centerLon).toFloat(),
+ viewport.screenY(cluster.centerLat).toFloat(),
+ clusterRadiusPx(cluster.size()),
+ )
+ }
+ if (hitCluster != null) {
+ /*
+ * A cluster click does not select a station. It moves towards the
+ * cluster and zooms in until the stations inside become visible,
+ * which is what the Leaflet map did.
+ */
+ mapState.centerLon = hitCluster.centerLon
+ mapState.centerLat = hitCluster.centerLat
+ mapState.zoom = minOf(
+ StationMapClusterer.DISABLE_AT_ZOOM,
+ mapState.zoom + 1.0,
+ ).toFloat()
+ return@detectTapGestures
+ }
+
+ val hit = result.individual().firstOrNull { marker ->
+ within(
+ offset,
+ viewport.screenX(marker.longitudeDeg()).toFloat(),
+ viewport.screenY(marker.latitudeDeg()).toFloat(),
+ MARKER_HIT_RADIUS_PX,
+ )
+ }
+ if (hit != null) {
+ onMarkerClicked?.invoke(hit)
+ }
+ }
+ }
+ ) {
+ @Suppress("UNUSED_EXPRESSION")
+ tileRevision
+
+ if (size.width <= 0f || size.height <= 0f) {
+ return@Canvas
+ }
+
+ val viewport = mapState.viewportFor(size.width, size.height)
+
+ drawRect(color = if (darkMode) MAP_BACKGROUND_DARK else MAP_BACKGROUND_LIGHT)
+
+ drawTiles(viewport, imageCache, darkMode)
+ drawMaidenheadGrid(viewport, textMeasurer, darkMode)
+ drawBeamAndPath(viewport, home, antennaAzimuthDeg, antennaBeamWidthDeg, maxQrbKm, markers, selectedMarkerCallsign, darkMode)
+ drawStations(viewport, clusterResult, selectedMarkerCallsign, textMeasurer, darkMode)
+ drawHover(viewport, hoverPosition, clusterResult, textMeasurer)
+ }
+}
+
+/* ------------------------------------------------------------------ tiles */
+
+/** The view a tile request was made for; the unit snapshotFlow deduplicates on. */
+private data class TileRequest(
+ val size: Size,
+ val zoom: Float,
+ val centerLon: Double,
+ val centerLat: Double,
+)
+
+/** One tile of the current view. */
+internal data class TileKey(val zoom: Int, val x: Int, val y: Int) {
+ val cacheKey: String get() = "$zoom/$x/$y"
+}
+
+/**
+ * The tiles the viewport covers, east-west wrap applied.
+ *
+ * The world wraps at the antimeridian but the tile index does not, so a view straddling
+ * it asks for indices outside 0..last and they are brought back round.
+ */
+internal fun visibleTileKeys(viewport: MapViewport): List {
+ val tileZoom = viewport.tileZoom()
+ val lastTileIndex = (1 shl tileZoom) - 1
+
+ val firstX = floor(viewport.worldXAt(0.0) / TILE_SIZE).toInt()
+ val lastX = floor(viewport.worldXAt(viewport.widthPx()) / TILE_SIZE).toInt()
+ val firstY = floor(viewport.worldYAt(0.0) / TILE_SIZE).toInt()
+ val lastY = floor(viewport.worldYAt(viewport.heightPx()) / TILE_SIZE).toInt()
+
+ val keys = mutableListOf()
+ for (tileY in firstY..lastY) {
+ if (tileY < 0 || tileY > lastTileIndex) {
+ continue
+ }
+ for (tileX in firstX..lastX) {
+ keys.add(TileKey(tileZoom, wrapTileX(tileX, lastTileIndex), tileY))
+ }
+ }
+ return keys
+}
+
+internal fun wrapTileX(tileX: Int, lastTileIndex: Int): Int {
+ val span = lastTileIndex + 1
+ val wrapped = tileX % span
+ return if (wrapped < 0) wrapped + span else wrapped
+}
+
+private fun DrawScope.drawTiles(
+ viewport: MapViewport,
+ imageCache: TileBitmapCache,
+ darkMode: Boolean,
+) {
+ val tileZoom = viewport.tileZoom()
+ val lastTileIndex = (1 shl tileZoom) - 1
+
+ /*
+ * Rounded up. A tile scaled to a fractional width leaves a hairline of background
+ * between itself and its neighbour otherwise, and a grid of hairlines reads as a
+ * broken map.
+ */
+ val tileSizePx = ceil(viewport.tileSizePx()).toInt().coerceAtLeast(1)
+
+ val firstX = floor(viewport.worldXAt(0.0) / TILE_SIZE).toInt()
+ val lastX = floor(viewport.worldXAt(viewport.widthPx()) / TILE_SIZE).toInt()
+ val firstY = floor(viewport.worldYAt(0.0) / TILE_SIZE).toInt()
+ val lastY = floor(viewport.worldYAt(viewport.heightPx()) / TILE_SIZE).toInt()
+
+ for (tileY in firstY..lastY) {
+ if (tileY < 0 || tileY > lastTileIndex) {
+ continue
+ }
+ for (tileX in firstX..lastX) {
+ val tile = imageCache[TileKey(tileZoom, wrapTileX(tileX, lastTileIndex), tileY).cacheKey]
+ ?: continue
+
+ drawImage(
+ image = tile,
+ dstOffset = IntOffset(
+ floor(viewport.screenXForWorldX(tileX * TILE_SIZE)).toInt(),
+ floor(viewport.screenYForWorldY(tileY * TILE_SIZE)).toInt(),
+ ),
+ dstSize = IntSize(tileSizePx, tileSizePx),
+ alpha = if (darkMode) DARK_TILE_ALPHA else 1.0f,
+ )
+ }
+ }
+}
+
+/* ------------------------------------------------------------- grid */
+
+private fun DrawScope.drawMaidenheadGrid(
+ viewport: MapViewport,
+ textMeasurer: TextMeasurer,
+ darkMode: Boolean,
+) {
+ /*
+ * The planner decides the precision, whether labels are shown at all, and how many
+ * cells apart they sit. Without it every cell gets a line and a label, which at
+ * six-digit precision is a carpet of text rather than a grid.
+ */
+ val plan = MaidenheadGridRenderPlanner.createPlan(
+ viewport.tileZoom(),
+ viewport.southLat(),
+ viewport.westLon(),
+ viewport.northLat(),
+ viewport.eastLon(),
+ viewport.widthPx(),
+ viewport.heightPx(),
+ )
+
+ val cells = MaidenheadGridUtils.buildVisibleCells(
+ viewport.southLat(),
+ viewport.westLon(),
+ viewport.northLat(),
+ viewport.eastLon(),
+ plan.precision(),
+ )
+
+ val lineColor = if (darkMode) GRID_LINE_DARK else GRID_LINE_LIGHT
+ val lineAlpha = if (darkMode) GRID_LINE_ALPHA_DARK else GRID_LINE_ALPHA_LIGHT
+
+ /*
+ * Unique lines, not one stroked rectangle per cell. Neighbouring cells share an
+ * edge, so a rectangle each paints every interior line twice: at 0.48 a shared edge
+ * reaches about 0.73 while the outermost line stays at 0.48, and the grid comes out
+ * visibly uneven. Leaflet drew each line once.
+ */
+ val verticals = sortedSetOf()
+ val horizontals = sortedSetOf()
+ for (cell in cells) {
+ verticals.add(viewport.screenX(cell.westLon()).toFloat())
+ verticals.add(viewport.screenX(cell.eastLon()).toFloat())
+ horizontals.add(viewport.screenY(cell.northLat()).toFloat())
+ horizontals.add(viewport.screenY(cell.southLat()).toFloat())
+ }
+
+ for (x in verticals) {
+ drawLine(
+ color = lineColor,
+ alpha = lineAlpha,
+ start = Offset(x, 0f),
+ end = Offset(x, size.height),
+ strokeWidth = GRID_LINE_WIDTH_PX,
+ )
+ }
+ for (y in horizontals) {
+ drawLine(
+ color = lineColor,
+ alpha = lineAlpha,
+ start = Offset(0f, y),
+ end = Offset(size.width, y),
+ strokeWidth = GRID_LINE_WIDTH_PX,
+ )
+ }
+
+ if (!plan.showLabels()) {
+ return
+ }
+
+ val labelStyle = TextStyle(
+ color = GRID_LABEL_COLOR,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = TextUnit(plan.labelFontSizePx().toFloat(), TextUnitType.Sp),
+ )
+ val labelBackground = if (darkMode) GRID_LABEL_BACKGROUND_DARK else GRID_LABEL_BACKGROUND_LIGHT
+
+ for (cell in cells) {
+ if (!plan.shouldShowLabel(cell) || cell.locatorLabel().isNullOrBlank()) {
+ continue
+ }
+
+ /* Centred in the cell, the way the stylesheet's translate(-50%, -50%) was. */
+ val centerX = viewport.screenX((cell.westLon() + cell.eastLon()) / 2.0).toFloat()
+ val centerY = viewport.screenY((cell.southLat() + cell.northLat()) / 2.0).toFloat()
+
+ val layout = textMeasurer.measure(cell.locatorLabel(), style = labelStyle)
+ val x = centerX - layout.size.width / 2f
+ val y = centerY - layout.size.height / 2f
+
+ drawRect(
+ color = labelBackground,
+ topLeft = Offset(x - GRID_LABEL_PADDING_PX, y),
+ size = Size(layout.size.width + GRID_LABEL_PADDING_PX * 2f, layout.size.height.toFloat()),
+ )
+ drawText(textLayoutResult = layout, topLeft = Offset(x, y))
+ }
+}
+
+/* ------------------------------------------------- beam and path */
+
+private fun DrawScope.drawBeamAndPath(
+ viewport: MapViewport,
+ home: Pair?,
+ antennaAzimuthDeg: Double,
+ antennaBeamWidthDeg: Double,
+ maxQrbKm: Double,
+ markers: List,
+ selectedMarkerCallsign: String?,
+ darkMode: Boolean,
+) {
+ if (home == null) {
+ return
+ }
+ val (homeLat, homeLon) = home
+
+ if (antennaBeamWidthDeg > 0.0 && maxQrbKm > 0.0) {
+ val polygon = Path()
+ buildBeamPolygon(homeLat, homeLon, antennaAzimuthDeg, antennaBeamWidthDeg, maxQrbKm)
+ .forEachIndexed { index, point ->
+ val x = viewport.screenX(point.second).toFloat()
+ val y = viewport.screenY(point.first).toFloat()
+ if (index == 0) polygon.moveTo(x, y) else polygon.lineTo(x, y)
+ }
+ polygon.close()
+
+ drawPath(path = polygon, color = BEAM_COLOR, alpha = BEAM_FILL_ALPHA, style = Fill)
+ drawPath(path = polygon, color = BEAM_COLOR, style = Stroke(width = BEAM_STROKE_WIDTH_PX))
+ }
+
+ val selected = markers.firstOrNull {
+ it.callSignRaw() == selectedMarkerCallsign && it.hasUsablePosition()
+ }
+ if (selected != null) {
+ drawLine(
+ color = if (darkMode) CONNECTION_COLOR_DARK else CONNECTION_COLOR_LIGHT,
+ alpha = CONNECTION_ALPHA,
+ start = Offset(viewport.screenX(homeLon).toFloat(), viewport.screenY(homeLat).toFloat()),
+ end = Offset(
+ viewport.screenX(selected.longitudeDeg()).toFloat(),
+ viewport.screenY(selected.latitudeDeg()).toFloat(),
+ ),
+ strokeWidth = CONNECTION_STROKE_WIDTH_PX,
+ pathEffect = PathEffect.dashPathEffect(CONNECTION_DASH_PX),
+ )
+ }
+
+ /*
+ * The Leaflet map drew no home marker; the beam's apex was the only hint of where
+ * home is, and with the beam switched off there was none. A small dot is kept here
+ * deliberately.
+ */
+ val homeX = viewport.screenX(homeLon).toFloat()
+ val homeY = viewport.screenY(homeLat).toFloat()
+ drawCircle(color = BEAM_COLOR, radius = HOME_RADIUS_PX, center = Offset(homeX, homeY))
+ drawCircle(color = Color.White, radius = HOME_INNER_RADIUS_PX, center = Offset(homeX, homeY))
+}
+
+/* ---------------------------------------------------------- stations */
+
+private fun DrawScope.drawStations(
+ viewport: MapViewport,
+ clusterResult: StationMapClusterer.Result,
+ selectedMarkerCallsign: String?,
+ textMeasurer: TextMeasurer,
+ darkMode: Boolean,
+) {
+ for (cluster in clusterResult.clusters()) {
+ drawClusterBubble(
+ x = viewport.screenX(cluster.centerLon).toFloat(),
+ y = viewport.screenY(cluster.centerLat).toFloat(),
+ count = cluster.size(),
+ containsWorked = cluster.getMarkers().any { it.worked() },
+ textMeasurer = textMeasurer,
+ )
+ }
+
+ for (marker in clusterResult.individual()) {
+ val x = viewport.screenX(marker.longitudeDeg()).toFloat()
+ val y = viewport.screenY(marker.latitudeDeg()).toFloat()
+ val selected = marker.callSignRaw() == selectedMarkerCallsign
+
+ drawStationDot(x, y, selected, marker.worked(), marker.warningToMyDirection())
+ drawStationLabel(x, y, marker.markerLabel(), marker.warningToMyDirection(), textMeasurer, darkMode)
+ }
+}
+
+/** 12 px across, 16 px when selected — the sizes `.station-dot` declared. */
+private fun DrawScope.drawStationDot(
+ x: Float,
+ y: Float,
+ selected: Boolean,
+ worked: Boolean,
+ warning: Boolean,
+) {
+ val radius = if (selected) SELECTED_DOT_RADIUS_PX else DOT_RADIUS_PX
+ val borderWidth = if (selected) SELECTED_DOT_BORDER_PX else DOT_BORDER_PX
+ val borderColor = when {
+ selected -> DOT_BORDER_SELECTED
+ warning -> DOT_BORDER_WARNING
+ worked -> DOT_BORDER_WORKED
+ else -> DOT_BORDER_DEFAULT
+ }
+
+ drawCircle(color = DOT_FILL, radius = radius, center = Offset(x, y))
+ drawCircle(
+ color = borderColor,
+ radius = radius - borderWidth / 2f,
+ center = Offset(x, y),
+ style = Stroke(width = borderWidth),
+ )
+}
+
+/** Up and to the right of the dot, as `.station-label` placed it. */
+private fun DrawScope.drawStationLabel(
+ x: Float,
+ y: Float,
+ label: String,
+ warning: Boolean,
+ textMeasurer: TextMeasurer,
+ darkMode: Boolean,
+) {
+ if (label.isBlank()) {
+ return
+ }
+
+ val layout = textMeasurer.measure(
+ label,
+ style = TextStyle(
+ color = if (warning) DOT_BORDER_WARNING else if (darkMode) LABEL_TEXT_DARK else LABEL_TEXT_LIGHT,
+ fontWeight = FontWeight.Bold,
+ fontSize = TextUnit(LABEL_FONT_SIZE_PX, TextUnitType.Sp),
+ ),
+ )
+
+ val boxLeft = x + LABEL_OFFSET_X_PX
+ val boxTop = y + LABEL_OFFSET_Y_PX
+ val boxWidth = layout.size.width + LABEL_PADDING_X_PX * 2f
+ val boxHeight = layout.size.height + LABEL_PADDING_Y_PX * 2f
+
+ drawRect(
+ color = if (darkMode) LABEL_BACKGROUND_DARK else LABEL_BACKGROUND_LIGHT,
+ topLeft = Offset(boxLeft, boxTop),
+ size = Size(boxWidth, boxHeight),
+ )
+ drawRect(
+ color = if (warning) DOT_BORDER_WARNING else if (darkMode) LABEL_BORDER_DARK else LABEL_BORDER_LIGHT,
+ topLeft = Offset(boxLeft, boxTop),
+ size = Size(boxWidth, boxHeight),
+ style = Stroke(width = LABEL_BORDER_WIDTH_PX),
+ )
+ drawText(
+ textLayoutResult = layout,
+ topLeft = Offset(boxLeft + LABEL_PADDING_X_PX, boxTop + LABEL_PADDING_Y_PX),
+ )
+}
+
+/** 32, 36 or 42 px across, by the thresholds `buildClusterMarkerHtml` used. */
+private fun DrawScope.drawClusterBubble(
+ x: Float,
+ y: Float,
+ count: Int,
+ containsWorked: Boolean,
+ textMeasurer: TextMeasurer,
+) {
+ val radius = clusterRadiusPx(count)
+ val borderColor = if (containsWorked) DOT_BORDER_WORKED else DOT_BORDER_DEFAULT
+
+ drawCircle(color = CLUSTER_FILL, radius = radius, center = Offset(x, y))
+ drawCircle(
+ color = borderColor,
+ radius = radius - DOT_BORDER_PX / 2f,
+ center = Offset(x, y),
+ style = Stroke(width = DOT_BORDER_PX),
+ )
+
+ val layout = textMeasurer.measure(
+ count.toString(),
+ style = TextStyle(
+ color = CLUSTER_TEXT,
+ fontWeight = FontWeight.ExtraBold,
+ fontSize = TextUnit(clusterFontSizePx(count), TextUnitType.Sp),
+ ),
+ )
+ drawText(
+ textLayoutResult = layout,
+ topLeft = Offset(x - layout.size.width / 2f, y - layout.size.height / 2f),
+ )
+}
+
+/* ------------------------------------------------------------ hover */
+
+private fun DrawScope.drawHover(
+ viewport: MapViewport,
+ hoverPosition: Offset?,
+ clusterResult: StationMapClusterer.Result,
+ textMeasurer: TextMeasurer,
+) {
+ val position = hoverPosition ?: return
+
+ val clusterUnderPointer = clusterResult.clusters().firstOrNull { cluster ->
+ within(
+ position,
+ viewport.screenX(cluster.centerLon).toFloat(),
+ viewport.screenY(cluster.centerLat).toFloat(),
+ clusterRadiusPx(cluster.size()),
+ )
+ }
+ val markerUnderPointer = clusterResult.individual().firstOrNull { marker ->
+ within(
+ position,
+ viewport.screenX(marker.longitudeDeg()).toFloat(),
+ viewport.screenY(marker.latitudeDeg()).toFloat(),
+ MARKER_HIT_RADIUS_PX,
+ )
+ }
+
+ val text = when {
+ clusterUnderPointer != null -> MapMarkerClusterTooltip.build(clusterUnderPointer)
+
+ markerUnderPointer != null -> markerUnderPointer.locator6().let { locator ->
+ if (locator.isBlank()) markerUnderPointer.displayCallSign()
+ else "${markerUnderPointer.displayCallSign()} ($locator)"
+ }
+
+ else -> return
+ }
+
+ val layout: TextLayoutResult = textMeasurer.measure(
+ text,
+ style = TextStyle(color = Color.Black, fontSize = TextUnit(LABEL_FONT_SIZE_PX, TextUnitType.Sp)),
+ )
+ val boxWidth = layout.size.width + TOOLTIP_PADDING_PX * 2f
+ val boxHeight = layout.size.height + TOOLTIP_PADDING_PX * 2f
+
+ /*
+ * Flipped away from the edge it would cross, then held inside the canvas. Flipping
+ * alone is not enough: a cluster tooltip is as tall as it has lines, and one taller
+ * than the canvas would otherwise be placed at a negative offset and cover the map
+ * it exists to explain.
+ */
+ val left = (
+ if (position.x + TOOLTIP_OFFSET_PX + boxWidth > size.width) {
+ position.x - TOOLTIP_OFFSET_PX - boxWidth
+ } else {
+ position.x + TOOLTIP_OFFSET_PX
+ }
+ ).coerceIn(0f, (size.width - boxWidth).coerceAtLeast(0f))
+ val top = (
+ if (position.y + TOOLTIP_OFFSET_PX + boxHeight > size.height) {
+ position.y - TOOLTIP_OFFSET_PX - boxHeight
+ } else {
+ position.y + TOOLTIP_OFFSET_PX
+ }
+ ).coerceIn(0f, (size.height - boxHeight).coerceAtLeast(0f))
+
+ drawRect(color = TOOLTIP_BACKGROUND, topLeft = Offset(left, top), size = Size(boxWidth, boxHeight))
+ drawRect(
+ color = TOOLTIP_BORDER,
+ topLeft = Offset(left, top),
+ size = Size(boxWidth, boxHeight),
+ style = Stroke(width = 1f),
+ )
+ drawText(
+ textLayoutResult = layout,
+ topLeft = Offset(left + TOOLTIP_PADDING_PX, top + TOOLTIP_PADDING_PX),
+ )
+}
+
+/* ----------------------------------------------------------- helpers */
+
+private val MapMarkerCluster.centerLon: Double get() = getCenterLon()
+private val MapMarkerCluster.centerLat: Double get() = getCenterLat()
+
+private fun within(point: Offset, x: Float, y: Float, radius: Float): Boolean {
+ val dx = point.x - x
+ val dy = point.y - y
+ return dx * dx + dy * dy <= radius * radius
+}
+
+private fun clusterRadiusPx(count: Int): Float = when {
+ count >= CLUSTER_LARGE_FROM -> CLUSTER_RADIUS_LARGE_PX
+ count >= CLUSTER_MEDIUM_FROM -> CLUSTER_RADIUS_MEDIUM_PX
+ else -> CLUSTER_RADIUS_SMALL_PX
+}
+
+private fun clusterFontSizePx(count: Int): Float = when {
+ count >= CLUSTER_LARGE_FROM -> 15f
+ count >= CLUSTER_MEDIUM_FROM -> 14f
+ else -> 13f
+}
+
+/** Null rather than a guess when the operator has no usable locator configured. */
+private fun homePosition(ownLocator6: String): Pair? {
+ if (ownLocator6.length < 4) {
+ return null
+ }
+ return try {
+ val location = kst4contest.locatorUtils.Location(ownLocator6)
+ Pair(location.latitude.toDegrees(), location.longitude.toDegrees())
+ } catch (malformedLocator: Exception) {
+ null
+ }
+}
+
+fun calculateDestinationPoint(
+ startLatDeg: Double,
+ startLonDeg: Double,
+ bearingDeg: Double,
+ distanceKm: Double,
+): Pair {
+ val earthRadiusKm = 6371.009
+ val angularDistance = distanceKm / earthRadiusKm
+ val bearingRad = Math.toRadians(bearingDeg)
+ val startLatRad = Math.toRadians(startLatDeg)
+ val startLonRad = Math.toRadians(startLonDeg)
+
+ val destLatRad = kotlin.math.asin(
+ kotlin.math.sin(startLatRad) * kotlin.math.cos(angularDistance) +
+ kotlin.math.cos(startLatRad) * kotlin.math.sin(angularDistance) * kotlin.math.cos(bearingRad)
+ )
+ val destLonRad = startLonRad + kotlin.math.atan2(
+ kotlin.math.sin(bearingRad) * kotlin.math.sin(angularDistance) * kotlin.math.cos(startLatRad),
+ kotlin.math.cos(angularDistance) - kotlin.math.sin(startLatRad) * kotlin.math.sin(destLatRad)
+ )
+
+ return Pair(Math.toDegrees(destLatRad), Math.toDegrees(destLonRad))
+}
+
+fun buildBeamPolygon(
+ startLatDeg: Double,
+ startLonDeg: Double,
+ centerAzimuthDeg: Double,
+ beamWidthDeg: Double,
+ radiusKm: Double,
+): List> {
+ val polygon = mutableListOf(Pair(startLatDeg, startLonDeg))
+
+ val segmentCount = maxOf(12, ceil(beamWidthDeg / 4.0).toInt())
+ val angleStep = beamWidthDeg / segmentCount
+ val startAzimuth = centerAzimuthDeg - beamWidthDeg / 2.0
+
+ for (segment in 0..segmentCount) {
+ var azimuth = startAzimuth + segment * angleStep
+ if (azimuth < 0.0) azimuth += 360.0
+ if (azimuth >= 360.0) azimuth -= 360.0
+ polygon.add(calculateDestinationPoint(startLatDeg, startLonDeg, azimuth, radiusKm))
+ }
+
+ polygon.add(Pair(startLatDeg, startLonDeg))
+ return polygon
+}
+
+/* --------------------------------------------------------- constants
+ *
+ * Every size is in canvas pixels and every colour is the one the Leaflet stylesheet
+ * declared, so the two maps can be held side by side.
+ */
+
+private const val TILE_SIZE = 256.0
+private const val DARK_TILE_ALPHA = 0.82f
+private const val ZOOM_STEP_PER_WHEEL_NOTCH = 0.5
+
+private val MAP_BACKGROUND_LIGHT = Color(0xFFEDE9DF)
+private val MAP_BACKGROUND_DARK = Color(0xFF23282D)
+
+private val GRID_LINE_DARK = Color(0xFFE1E7EC)
+private val GRID_LINE_LIGHT = Color(0xFF46586C)
+private const val GRID_LINE_ALPHA_DARK = 0.48f
+private const val GRID_LINE_ALPHA_LIGHT = 0.56f
+private const val GRID_LINE_WIDTH_PX = 1.4f
+
+private val GRID_LABEL_COLOR = Color(0xFF63067A)
+private val GRID_LABEL_BACKGROUND_LIGHT = Color.White.copy(alpha = 0.18f)
+private val GRID_LABEL_BACKGROUND_DARK = Color(0xFF22262B).copy(alpha = 0.20f)
+private const val GRID_LABEL_PADDING_PX = 3f
+
+private val DOT_FILL = Color(0xFF1D1D1D)
+private val DOT_BORDER_DEFAULT = Color(0xFF4DA6FF)
+private val DOT_BORDER_WORKED = Color(0xFFFFD24D)
+private val DOT_BORDER_WARNING = Color(0xFF00FF66)
+private val DOT_BORDER_SELECTED = Color(0xFFFF9900)
+private const val DOT_RADIUS_PX = 6f
+private const val DOT_BORDER_PX = 2f
+private const val SELECTED_DOT_RADIUS_PX = 8f
+private const val SELECTED_DOT_BORDER_PX = 3f
+private const val MARKER_HIT_RADIUS_PX = 10f
+
+private val LABEL_BACKGROUND_LIGHT = Color(0xFFF8F8F8).copy(alpha = 0.95f)
+private val LABEL_BACKGROUND_DARK = Color(0xFF24282D).copy(alpha = 0.96f)
+private val LABEL_TEXT_LIGHT = Color(0xFF1C1C1C)
+private val LABEL_TEXT_DARK = Color(0xFFF1F3F5)
+private val LABEL_BORDER_LIGHT = Color.Black.copy(alpha = 0.20f)
+private val LABEL_BORDER_DARK = Color.White.copy(alpha = 0.18f)
+private const val LABEL_FONT_SIZE_PX = 12f
+private const val LABEL_BORDER_WIDTH_PX = 1f
+private const val LABEL_PADDING_X_PX = 5f
+private const val LABEL_PADDING_Y_PX = 2f
+private const val LABEL_OFFSET_X_PX = 10f
+private const val LABEL_OFFSET_Y_PX = -22f
+
+private val CLUSTER_FILL = Color(0xFF202428)
+private val CLUSTER_TEXT = Color(0xFFF1F3F5)
+private const val CLUSTER_MEDIUM_FROM = 8
+private const val CLUSTER_LARGE_FROM = 20
+private const val CLUSTER_RADIUS_SMALL_PX = 16f
+private const val CLUSTER_RADIUS_MEDIUM_PX = 18f
+private const val CLUSTER_RADIUS_LARGE_PX = 21f
+
+private val BEAM_COLOR = Color(0xFFFF4D4D)
+private const val BEAM_FILL_ALPHA = 0.12f
+private const val BEAM_STROKE_WIDTH_PX = 2f
+private const val HOME_RADIUS_PX = 6f
+private const val HOME_INNER_RADIUS_PX = 2f
+
+private val CONNECTION_COLOR_DARK = Color(0xFF2FD7FF)
+private val CONNECTION_COLOR_LIGHT = Color(0xFF00A4CF)
+private const val CONNECTION_ALPHA = 0.85f
+private const val CONNECTION_STROKE_WIDTH_PX = 2f
+private val CONNECTION_DASH_PX = floatArrayOf(6f, 6f)
+
+private val TOOLTIP_BACKGROUND = Color.White.copy(alpha = 0.92f)
+private val TOOLTIP_BORDER = Color.Black
+private const val TOOLTIP_PADDING_PX = 4f
+private const val TOOLTIP_OFFSET_PX = 12f
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/map/MapOverlayControls.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/MapOverlayControls.kt
new file mode 100644
index 00000000..41e9e751
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/MapOverlayControls.kt
@@ -0,0 +1,116 @@
+package kst4contest.view.compose.map
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.unit.TextUnitType
+import androidx.compose.ui.unit.dp
+import kst4contest.view.map.MapViewport
+
+/** The zoom range the tile source serves, as the viewport defines it. */
+internal val MIN_MAP_ZOOM = MapViewport.MIN_ZOOM.toFloat()
+internal val MAX_MAP_ZOOM = MapViewport.MAX_ZOOM.toFloat()
+
+/**
+ * The two things Leaflet drew on top of the map itself.
+ *
+ * The zoom buttons, because a map without them can only be zoomed by a wheel or a
+ * trackpad and neither is a given on a contest laptop. And the OpenStreetMap
+ * attribution, which is not decoration: the tile licence requires it to be visible
+ * wherever the tiles are.
+ */
+@Composable
+internal fun BoxScope.MapOverlayControls(
+ darkMode: Boolean,
+ tilesUnavailable: Boolean,
+ onZoomIn: () -> Unit,
+ onZoomOut: () -> Unit,
+) {
+ Column(
+ modifier = Modifier.align(Alignment.TopStart).padding(10.dp),
+ ) {
+ ZoomButton("+", darkMode, onZoomIn)
+ ZoomButton("−", darkMode, onZoomOut)
+ }
+
+ if (tilesUnavailable) {
+ /*
+ * Said out loud. An empty map and a map with no stations on it look the same,
+ * and an operator in a contest should not have to guess which one they have.
+ */
+ Text(
+ text = "Map tiles unavailable — check the network connection.",
+ color = TILE_WARNING_TEXT,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .padding(top = 10.dp)
+ .background(TILE_WARNING_BACKGROUND, RoundedCornerShape(4.dp))
+ .padding(horizontal = 8.dp, vertical = 4.dp),
+ )
+ }
+
+ Text(
+ text = "© OpenStreetMap contributors",
+ fontSize = TextUnit(10f, TextUnitType.Sp),
+ color = if (darkMode) ATTRIBUTION_TEXT_DARK else ATTRIBUTION_TEXT_LIGHT,
+ modifier = Modifier
+ .align(Alignment.BottomEnd)
+ .background(if (darkMode) ATTRIBUTION_BACKGROUND_DARK else ATTRIBUTION_BACKGROUND_LIGHT)
+ .padding(horizontal = 4.dp, vertical = 1.dp),
+ )
+}
+
+@Composable
+private fun ZoomButton(label: String, darkMode: Boolean, onClick: () -> Unit) {
+ Box(
+ modifier = Modifier
+ .size(26.dp)
+ .background(
+ if (darkMode) CONTROL_BACKGROUND_DARK else CONTROL_BACKGROUND_LIGHT,
+ RoundedCornerShape(3.dp),
+ )
+ .border(
+ 1.dp,
+ if (darkMode) CONTROL_BORDER_DARK else CONTROL_BORDER_LIGHT,
+ RoundedCornerShape(3.dp),
+ )
+ .clickable(onClick = onClick),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = label,
+ color = if (darkMode) CONTROL_TEXT_DARK else CONTROL_TEXT_LIGHT,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center,
+ )
+ }
+}
+
+/* The `--control-*` and `--attribution-*` values of the Leaflet stylesheet. */
+private val CONTROL_BACKGROUND_LIGHT = Color(0xFFFFFFFF).copy(alpha = 0.96f)
+private val CONTROL_BACKGROUND_DARK = Color(0xFF373E43).copy(alpha = 0.96f)
+private val CONTROL_TEXT_LIGHT = Color(0xFF242424)
+private val CONTROL_TEXT_DARK = Color(0xFFE2E6EA)
+private val CONTROL_BORDER_LIGHT = Color(0xFFB7B7B7)
+private val CONTROL_BORDER_DARK = Color(0xFF556068)
+private val ATTRIBUTION_BACKGROUND_LIGHT = Color(0xFFFFFFFF).copy(alpha = 0.88f)
+private val ATTRIBUTION_BACKGROUND_DARK = Color(0xFF22262B).copy(alpha = 0.86f)
+private val ATTRIBUTION_TEXT_LIGHT = Color(0xFF2D2D2D)
+private val ATTRIBUTION_TEXT_DARK = Color(0xFFD2D8DD)
+private val TILE_WARNING_TEXT = Color(0xFFFFE0B2)
+private val TILE_WARNING_BACKGROUND = Color(0xFF5A2B20).copy(alpha = 0.92f)
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/map/PathAnalysisDetails.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/PathAnalysisDetails.kt
new file mode 100644
index 00000000..877a560b
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/PathAnalysisDetails.kt
@@ -0,0 +1,148 @@
+package kst4contest.view.compose.map
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.unit.TextUnitType
+import androidx.compose.ui.unit.dp
+import kst4contest.view.map.PathAnalysisResult
+
+/**
+ * The path and terrain analysis column of the station map window.
+ *
+ * It lives in its own file so that it can be composed in a test. The window around it
+ * needs a running application runtime and a chat controller; this column needs nothing
+ * but a result, which is what made the layout defect below invisible to the suite for
+ * as long as it existed.
+ */
+@Composable
+internal fun PathAnalysisDetails(
+ result: PathAnalysisResult?,
+ darkMode: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ Column(modifier) {
+ Text(
+ "Path / terrain analysis",
+ fontSize = TextUnit(12f, TextUnitType.Sp),
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(top = 2.dp),
+ )
+ HorizontalDivider(Modifier.padding(vertical = 2.dp))
+
+ if (result == null) {
+ Text(
+ "Select a station to prepare path and terrain analysis.",
+ fontSize = TextUnit(12f, TextUnitType.Sp),
+ )
+ return@Column
+ }
+
+ DetailRow("From locator:", result.fromLocator6().ifBlank { "-" })
+ DetailRow("To locator:", result.toLocator6().ifBlank { "-" })
+ DetailRow("Distance/QTF:", "${result.distanceText()} / ${result.bearingText()}")
+ DetailRow("Endpoints:", result.endpointSummaryText())
+ DetailRow("Source:", "${result.analysisMode().ifBlank { "-" }} / ${result.profilePoints().size} samples")
+ DetailRow("Frequency:", result.analysisFrequencyText())
+ DetailRow("Refraction:", result.effectiveEarthRadiusText())
+ DetailRow("Radio horizon:", result.radioHorizonText())
+ DetailRow("Terrain horizon:", result.terrainHorizonText())
+ DetailRow("Fresnel:", "${result.fresnelText()} / ${result.worstFresnelClearanceText()}")
+ DetailRow("Obstruction:", result.obstructionText())
+
+ val severity = result.propagationSeverityLevel()
+ val textColor = if (darkMode) Color(0xFFF0F0F0) else Color(0xFF202020)
+ val backgroundColor = when (severity) {
+ 1 -> if (darkMode) Color(0xFF1F4D2B) else Color(0xFFD8F3DC)
+ 2 -> if (darkMode) Color(0xFF4A4420) else Color(0xFFFFF3BF)
+ 3 -> if (darkMode) Color(0xFF4D3520) else Color(0xFFFFE0B2)
+ 4 -> if (darkMode) Color(0xFF5A2B20) else Color(0xFFFFC9A9)
+ 5 -> if (darkMode) Color(0xFF5A2020) else Color(0xFFFFCDD2)
+ else -> if (darkMode) Color(0xFF33383E) else Color(0xFFEEEEEE)
+ }
+ val borderColor = when (severity) {
+ 1 -> Color(0xFF3AA655)
+ 2 -> Color(0xFFD4A017)
+ 3 -> Color(0xFFE69138)
+ 4 -> Color(0xFFD96C2C)
+ 5 -> Color(0xFFD63B3B)
+ else -> if (darkMode) Color(0xFF666F78) else Color(0xFFCCCCCC)
+ }
+
+ Row(Modifier.padding(vertical = 2.dp).fillMaxWidth()) {
+ Text(
+ "Assessment:",
+ modifier = Modifier.weight(LABEL_WEIGHT),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontSize = TextUnit(11f, TextUnitType.Sp),
+ lineHeight = TextUnit(13f, TextUnitType.Sp),
+ )
+ Box(modifier = Modifier.weight(VALUE_WEIGHT)) {
+ Box(
+ modifier = Modifier
+ .background(backgroundColor, RoundedCornerShape(4.dp))
+ .border(1.dp, borderColor, RoundedCornerShape(4.dp))
+ .padding(horizontal = 6.dp, vertical = 3.dp),
+ ) {
+ Text(result.propagationAssessmentText(), color = textColor, fontWeight = FontWeight.Bold)
+ }
+ }
+ }
+
+ DetailRow("Link budget:", result.linkBudgetText())
+ DetailRow("RX power:", result.linkBudgetRxPowerText())
+ DetailRow("CW hint:", result.cwHintText())
+ DetailRow("Mechanisms:", result.propagationMechanismsText(), valueColor = textColor, valueBold = true)
+ DetailRow("LOS:", "${result.losText()} / worst ${result.worstClearanceText()}")
+ DetailRow("Status:", result.statusText())
+ }
+}
+
+/**
+ * One label and its value.
+ *
+ * Both columns are weighted, and the assessment row above follows the same two weights
+ * rather than a fixed label width. A fixed width does not shrink: at a narrow pane it
+ * keeps its dp and the value beside it gets whatever is left, which was 35% of the pane
+ * at 200 px and nothing at all at 60 px.
+ */
+@Composable
+private fun DetailRow(
+ label: String,
+ value: String,
+ valueColor: Color = Color.Unspecified,
+ valueBold: Boolean = false,
+) {
+ Row(Modifier.fillMaxWidth()) {
+ Text(
+ text = label,
+ modifier = Modifier.weight(LABEL_WEIGHT),
+ fontSize = TextUnit(11f, TextUnitType.Sp),
+ lineHeight = TextUnit(13f, TextUnitType.Sp),
+ )
+ Text(
+ text = value,
+ modifier = Modifier.weight(VALUE_WEIGHT),
+ color = valueColor,
+ fontWeight = if (valueBold) FontWeight.Bold else null,
+ fontSize = TextUnit(11f, TextUnitType.Sp),
+ lineHeight = TextUnit(13f, TextUnitType.Sp),
+ )
+ }
+}
+
+private const val LABEL_WEIGHT = 0.38f
+private const val VALUE_WEIGHT = 0.62f
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/map/PathProfileChart.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/PathProfileChart.kt
new file mode 100644
index 00000000..44ae16e8
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/PathProfileChart.kt
@@ -0,0 +1,573 @@
+package kst4contest.view.compose.map
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Path
+import androidx.compose.ui.graphics.PathEffect
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.drawscope.Fill
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.input.pointer.PointerEventType
+import androidx.compose.ui.input.pointer.onPointerEvent
+import androidx.compose.ui.text.TextMeasurer
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.drawText
+import androidx.compose.ui.text.rememberTextMeasurer
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.ExperimentalComposeUiApi
+import kst4contest.view.map.PathGeometryUtils
+import kst4contest.view.map.PathHorizonSummary
+import kst4contest.view.map.PathObstructionSummary
+import kst4contest.view.map.PathProfilePoint
+import java.util.Locale
+import kotlin.math.abs
+import kotlin.math.max
+import kotlin.math.min
+
+@OptIn(ExperimentalComposeUiApi::class)
+@Composable
+fun PathProfileChart(
+ modifier: Modifier = Modifier,
+ profilePoints: List = emptyList(),
+ totalDistanceKm: Double = Double.NaN,
+ darkMode: Boolean = false,
+ homeAntennaHeightMeters: Double = Double.NaN,
+ targetAntennaHeightMeters: Double = Double.NaN,
+ analysisFrequencyMHz: Double = Double.NaN,
+ effectiveEarthRadiusFactor: Double = PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
+ horizonSummary: PathHorizonSummary = PathHorizonSummary.empty(),
+ obstructionSummary: PathObstructionSummary = PathObstructionSummary.empty(),
+ onProfilePointHovered: ((PathProfilePoint?) -> Unit)? = null
+) {
+ val textMeasurer = rememberTextMeasurer()
+ val textStyle = TextStyle(fontSize = 12.sp)
+
+ Canvas(modifier = modifier
+ .onPointerEvent(PointerEventType.Move) { event ->
+ if (onProfilePointHovered != null && profilePoints.isNotEmpty()) {
+ val position = event.changes.first().position
+ val x = position.x.toDouble()
+ val left = 58.0
+ val right = 18.0
+ // For a proper hover, we need the total width of the Canvas, which we can get from the layout or event
+ // event.changes bounds? We can assume the size is roughly available if we store it.
+ // Alternatively, we use awaitPointerEventScope inside pointerInput which gives access to size.
+ }
+ }
+ .onPointerEvent(PointerEventType.Exit) {
+ onProfilePointHovered?.invoke(null)
+ }
+ ) {
+ val width = size.width.toDouble()
+ val height = size.height.toDouble()
+
+ if (width <= 1.0 || height <= 1.0) {
+ return@Canvas
+ }
+
+ // --- Colors ---
+ val background = if (darkMode) Color(34, 39, 43) else Color(248, 248, 248)
+ val border = if (darkMode) Color(96, 106, 116) else Color(180, 180, 180)
+ val gridColor = if (darkMode) Color(95, 105, 115, (255 * 0.70).toInt()) else Color(185, 185, 185, (255 * 0.75).toInt())
+ val textColor = if (darkMode) Color(230, 235, 239) else Color(40, 40, 40)
+
+ val terrainFill = if (darkMode) Color(70, 165, 95, (255 * 0.35).toInt()) else Color(90, 180, 110, (255 * 0.35).toInt())
+ val terrainLine = if (darkMode) Color(95, 210, 120) else Color(45, 150, 70)
+
+ val losLine = if (darkMode) Color(255, 182, 80) else Color(220, 130, 20)
+
+ val fresnelFill = if (darkMode) Color(170, 135, 255, (255 * 0.16).toInt()) else Color(150, 105, 240, (255 * 0.16).toInt())
+ val fresnelLine = if (darkMode) Color(205, 155, 255) else Color(130, 75, 210)
+
+ val endpointMarker = if (darkMode) Color(245, 245, 245) else Color(45, 45, 45)
+ val horizonMarker = if (darkMode) Color(95, 190, 255) else Color(30, 120, 200)
+ val criticalMarker = if (darkMode) Color(255, 100, 100) else Color(210, 45, 45)
+
+ val terrainHorizonMarker = if (darkMode) Color(255, 220, 105) else Color(185, 125, 0)
+ val obstructionMarker = if (darkMode) Color(255, 135, 75) else Color(225, 85, 30)
+
+ drawRect(color = background)
+ drawRect(
+ color = border,
+ topLeft = Offset(0.5f, 0.5f),
+ size = Size((width - 1.0).toFloat(), (height - 1.0).toFloat()),
+ style = Stroke(1.0f)
+ )
+
+ val left = 58.0
+ val headerTop = 12.0
+ val headerHeight = 34.0
+ val endpointTextHeight = 18.0
+ val top = headerTop + headerHeight + endpointTextHeight
+ val right = 18.0
+ val bottom = 56.0
+
+ val plotX = left
+ val plotY = top
+ val plotWidth = max(10.0, width - left - right)
+ val plotHeight = max(10.0, height - top - bottom)
+
+ // Draw Grid
+ for (i in 0..4) {
+ val y = plotY + plotHeight * i / 4.0
+ drawLine(gridColor, Offset(plotX.toFloat(), y.toFloat()), Offset((plotX + plotWidth).toFloat(), y.toFloat()), strokeWidth = 1f)
+ }
+ for (i in 0..4) {
+ val x = plotX + plotWidth * i / 4.0
+ drawLine(gridColor, Offset(x.toFloat(), plotY.toFloat()), Offset(x.toFloat(), (plotY + plotHeight).toFloat()), strokeWidth = 1f)
+ }
+
+ fun estimateTextWidth(text: String?): Double {
+ if (text.isNullOrBlank()) return 0.0
+ // Simple approximation like in JavaFX code
+ return text.length * 6.2
+ }
+
+ fun drawAxisLabels(minElevation: Double, maxElevation: Double) {
+ val style = textStyle.copy(color = textColor)
+ for (i in 0..4) {
+ val fraction = i / 4.0
+ val y = plotY + plotHeight - fraction * plotHeight
+ val value = minElevation + fraction * (maxElevation - minElevation)
+ safeDrawText(textMeasurer, String.format(Locale.US, "%.0f", value), topLeft = Offset(8f, (y - 6).toFloat()), style = style)
+ }
+ val xFractions = doubleArrayOf(0.0, 0.25, 0.50, 0.75, 1.0)
+ for (xFraction in xFractions) {
+ val x = plotX + xFraction * plotWidth
+ val distance = if (totalDistanceKm.isFinite()) totalDistanceKm * xFraction else 0.0
+ safeDrawText(textMeasurer, String.format(Locale.US, "%.0f", distance), topLeft = Offset((x - 8).toFloat(), (plotY + plotHeight + 16.0).toFloat()), style = style)
+ }
+ safeDrawText(textMeasurer, "Height [m]", topLeft = Offset(plotX.toFloat(), (plotY - 14.0).toFloat()), style = style)
+ safeDrawText(textMeasurer, "Distance [km]", topLeft = Offset((plotX + plotWidth / 2.0 - 30.0).toFloat(), (plotY + plotHeight + 34.0).toFloat()), style = style)
+ if (analysisFrequencyMHz.isFinite() && analysisFrequencyMHz > 0.0) {
+ val freqText = String.format(Locale.US, "f = %.3f MHz", analysisFrequencyMHz)
+ safeDrawText(textMeasurer, freqText, topLeft = Offset((plotX + plotWidth - estimateTextWidth(freqText)).toFloat(), (plotY + plotHeight + 34.0).toFloat()), style = style)
+ }
+ }
+
+ if (profilePoints.isEmpty()) {
+ safeDrawText(textMeasurer, "No profile samples available.", topLeft = Offset(plotX.toFloat(), (plotY + plotHeight / 2.0).toFloat()), style = textStyle.copy(color = textColor))
+ drawAxisLabels(0.0, 1.0)
+ return@Canvas
+ }
+
+ fun terrainDisplayElevationMeters(point: PathProfilePoint?): Double {
+ if (point == null) return Double.NaN
+ if (point.curvatureAdjustedElevationMeters().isFinite()) {
+ return point.curvatureAdjustedElevationMeters()
+ }
+ return PathGeometryUtils.calculateCurvatureAdjustedElevationMeters(point, totalDistanceKm)
+ }
+
+ fun determineMinimumElevation(): Double {
+ var minElev = Double.POSITIVE_INFINITY
+ for (point in profilePoints) {
+ minElev = min(minElev, terrainDisplayElevationMeters(point))
+ if (point.lineOfSightHeightMeters().isFinite()) minElev = min(minElev, point.lineOfSightHeightMeters())
+ if (point.fresnelUpperHeightMeters().isFinite()) minElev = min(minElev, point.fresnelUpperHeightMeters())
+ if (point.fresnelLowerHeightMeters().isFinite()) minElev = min(minElev, point.fresnelLowerHeightMeters())
+ }
+ return if (minElev.isInfinite()) 0.0 else minElev
+ }
+
+ fun determineMaximumElevation(): Double {
+ var maxElev = Double.NEGATIVE_INFINITY
+ for (point in profilePoints) {
+ maxElev = max(maxElev, terrainDisplayElevationMeters(point))
+ if (point.lineOfSightHeightMeters().isFinite()) maxElev = max(maxElev, point.lineOfSightHeightMeters())
+ if (point.fresnelUpperHeightMeters().isFinite()) maxElev = max(maxElev, point.fresnelUpperHeightMeters())
+ if (point.fresnelLowerHeightMeters().isFinite()) maxElev = max(maxElev, point.fresnelLowerHeightMeters())
+ }
+ return if (maxElev.isInfinite()) 1.0 else maxElev
+ }
+
+ var minElevation = determineMinimumElevation()
+ var maxElevation = determineMaximumElevation()
+ var elevationRange = max(1.0, maxElevation - minElevation)
+ val paddingMeters = max(20.0, elevationRange * 0.08)
+
+ minElevation -= paddingMeters
+ maxElevation += paddingMeters
+ elevationRange = max(1.0, maxElevation - minElevation)
+
+ fun normalizeDistance(distanceKm: Double): Double {
+ if (!totalDistanceKm.isFinite() || totalDistanceKm <= 0.0) return 0.0
+ return max(0.0, min(1.0, distanceKm / totalDistanceKm))
+ }
+
+ fun normalizeElevation(elevationMeters: Double, minElev: Double, elevRange: Double): Double {
+ return max(0.0, min(1.0, (elevationMeters - minElev) / elevRange))
+ }
+
+ fun hasEnrichedLosGeometry(): Boolean {
+ return profilePoints.any { it.lineOfSightHeightMeters().isFinite() }
+ }
+
+ fun hasEnrichedFresnelGeometry(): Boolean {
+ return profilePoints.any { it.fresnelUpperHeightMeters().isFinite() || it.fresnelLowerHeightMeters().isFinite() }
+ }
+
+ // Draw terrain fill
+ if (profilePoints.size >= 2) {
+ val path = Path()
+ path.moveTo((plotX + normalizeDistance(profilePoints.first().distanceKm()) * plotWidth).toFloat(), (plotY + plotHeight).toFloat())
+ for (point in profilePoints) {
+ val x = plotX + normalizeDistance(point.distanceKm()) * plotWidth
+ val y = plotY + plotHeight - normalizeElevation(terrainDisplayElevationMeters(point), minElevation, elevationRange) * plotHeight
+ path.lineTo(x.toFloat(), y.toFloat())
+ }
+ path.lineTo((plotX + normalizeDistance(profilePoints.last().distanceKm()) * plotWidth).toFloat(), (plotY + plotHeight).toFloat())
+ path.close()
+ drawPath(path, color = terrainFill, style = Fill)
+ }
+
+ // Draw Fresnel fill
+ if (hasEnrichedFresnelGeometry() && profilePoints.size >= 2) {
+ val allFresnelFinite = profilePoints.all { it.fresnelUpperHeightMeters().isFinite() && it.fresnelLowerHeightMeters().isFinite() }
+ if (allFresnelFinite) {
+ val path = Path()
+ path.moveTo((plotX + normalizeDistance(profilePoints.first().distanceKm()) * plotWidth).toFloat(),
+ (plotY + plotHeight - normalizeElevation(profilePoints.first().fresnelUpperHeightMeters(), minElevation, elevationRange) * plotHeight).toFloat())
+
+ for (point in profilePoints) {
+ val x = plotX + normalizeDistance(point.distanceKm()) * plotWidth
+ val y = plotY + plotHeight - normalizeElevation(point.fresnelUpperHeightMeters(), minElevation, elevationRange) * plotHeight
+ path.lineTo(x.toFloat(), y.toFloat())
+ }
+ for (i in profilePoints.indices.reversed()) {
+ val point = profilePoints[i]
+ val x = plotX + normalizeDistance(point.distanceKm()) * plotWidth
+ val y = plotY + plotHeight - normalizeElevation(point.fresnelLowerHeightMeters(), minElevation, elevationRange) * plotHeight
+ path.lineTo(x.toFloat(), y.toFloat())
+ }
+ path.close()
+ drawPath(path, color = fresnelFill, style = Fill)
+ }
+ }
+
+ // Draw terrain line
+ for (i in 1 until profilePoints.size) {
+ val prev = profilePoints[i - 1]
+ val curr = profilePoints[i]
+ val x1 = plotX + normalizeDistance(prev.distanceKm()) * plotWidth
+ val y1 = plotY + plotHeight - normalizeElevation(terrainDisplayElevationMeters(prev), minElevation, elevationRange) * plotHeight
+ val x2 = plotX + normalizeDistance(curr.distanceKm()) * plotWidth
+ val y2 = plotY + plotHeight - normalizeElevation(terrainDisplayElevationMeters(curr), minElevation, elevationRange) * plotHeight
+ drawLine(terrainLine, Offset(x1.toFloat(), y1.toFloat()), Offset(x2.toFloat(), y2.toFloat()), strokeWidth = 2f)
+ }
+
+ // Draw Fresnel hull
+ if (hasEnrichedFresnelGeometry()) {
+ val dashEffect = PathEffect.dashPathEffect(floatArrayOf(6f, 4f))
+ for (i in 1 until profilePoints.size) {
+ val prev = profilePoints[i - 1]
+ val curr = profilePoints[i]
+ if (prev.fresnelUpperHeightMeters().isFinite() && curr.fresnelUpperHeightMeters().isFinite()) {
+ val x1 = plotX + normalizeDistance(prev.distanceKm()) * plotWidth
+ val y1 = plotY + plotHeight - normalizeElevation(prev.fresnelUpperHeightMeters(), minElevation, elevationRange) * plotHeight
+ val x2 = plotX + normalizeDistance(curr.distanceKm()) * plotWidth
+ val y2 = plotY + plotHeight - normalizeElevation(curr.fresnelUpperHeightMeters(), minElevation, elevationRange) * plotHeight
+ drawLine(fresnelLine, Offset(x1.toFloat(), y1.toFloat()), Offset(x2.toFloat(), y2.toFloat()), strokeWidth = 1f, pathEffect = dashEffect)
+ }
+ if (prev.fresnelLowerHeightMeters().isFinite() && curr.fresnelLowerHeightMeters().isFinite()) {
+ val x1 = plotX + normalizeDistance(prev.distanceKm()) * plotWidth
+ val y1 = plotY + plotHeight - normalizeElevation(prev.fresnelLowerHeightMeters(), minElevation, elevationRange) * plotHeight
+ val x2 = plotX + normalizeDistance(curr.distanceKm()) * plotWidth
+ val y2 = plotY + plotHeight - normalizeElevation(curr.fresnelLowerHeightMeters(), minElevation, elevationRange) * plotHeight
+ drawLine(fresnelLine, Offset(x1.toFloat(), y1.toFloat()), Offset(x2.toFloat(), y2.toFloat()), strokeWidth = 1f, pathEffect = dashEffect)
+ }
+ }
+ }
+
+ // Draw LOS line
+ if (hasEnrichedLosGeometry()) {
+ for (i in 1 until profilePoints.size) {
+ val prev = profilePoints[i - 1]
+ val curr = profilePoints[i]
+ if (prev.lineOfSightHeightMeters().isFinite() && curr.lineOfSightHeightMeters().isFinite()) {
+ val x1 = plotX + normalizeDistance(prev.distanceKm()) * plotWidth
+ val y1 = plotY + plotHeight - normalizeElevation(prev.lineOfSightHeightMeters(), minElevation, elevationRange) * plotHeight
+ val x2 = plotX + normalizeDistance(curr.distanceKm()) * plotWidth
+ val y2 = plotY + plotHeight - normalizeElevation(curr.lineOfSightHeightMeters(), minElevation, elevationRange) * plotHeight
+ drawLine(losLine, Offset(x1.toFloat(), y1.toFloat()), Offset(x2.toFloat(), y2.toFloat()), strokeWidth = 1.5f)
+ }
+ }
+ } else if (profilePoints.size >= 2 && homeAntennaHeightMeters.isFinite() && targetAntennaHeightMeters.isFinite()) {
+ val startAntennaMeters = terrainDisplayElevationMeters(profilePoints.first()) + homeAntennaHeightMeters
+ val endAntennaMeters = terrainDisplayElevationMeters(profilePoints.last()) + targetAntennaHeightMeters
+ val x1 = plotX
+ val y1 = plotY + plotHeight - normalizeElevation(startAntennaMeters, minElevation, elevationRange) * plotHeight
+ val x2 = plotX + plotWidth
+ val y2 = plotY + plotHeight - normalizeElevation(endAntennaMeters, minElevation, elevationRange) * plotHeight
+ drawLine(losLine, Offset(x1.toFloat(), y1.toFloat()), Offset(x2.toFloat(), y2.toFloat()), strokeWidth = 1.5f)
+ }
+
+ fun buildEndpointLabel(name: String, groundMetersAsl: Double, antennaHeightMetersAgl: Double): String {
+ val hasGround = groundMetersAsl.isFinite()
+ val hasAntenna = antennaHeightMetersAgl.isFinite()
+ if (hasGround && hasAntenna) return String.format(Locale.US, "%s: %.0f m ASL + %.0f m AGL", name, groundMetersAsl, antennaHeightMetersAgl)
+ if (hasGround) return String.format(Locale.US, "%s: %.0f m ASL", name, groundMetersAsl)
+ if (hasAntenna) return String.format(Locale.US, "%s: +%.0f m AGL", name, antennaHeightMetersAgl)
+ return "$name: -"
+ }
+
+ // Endpoint markers
+ if (profilePoints.size >= 2) {
+ val first = profilePoints.first()
+ val last = profilePoints.last()
+ val firstGround = terrainDisplayElevationMeters(first)
+ val lastGround = terrainDisplayElevationMeters(last)
+ val firstAntenna = if (first.lineOfSightHeightMeters().isFinite()) first.lineOfSightHeightMeters() else firstGround + homeAntennaHeightMeters
+ val lastAntenna = if (last.lineOfSightHeightMeters().isFinite()) last.lineOfSightHeightMeters() else lastGround + targetAntennaHeightMeters
+
+ val x1 = plotX
+ val y1Ground = plotY + plotHeight - normalizeElevation(firstGround, minElevation, elevationRange) * plotHeight
+ val y1Antenna = plotY + plotHeight - normalizeElevation(firstAntenna, minElevation, elevationRange) * plotHeight
+
+ val x2 = plotX + plotWidth
+ val y2Ground = plotY + plotHeight - normalizeElevation(lastGround, minElevation, elevationRange) * plotHeight
+ val y2Antenna = plotY + plotHeight - normalizeElevation(lastAntenna, minElevation, elevationRange) * plotHeight
+
+ drawLine(endpointMarker, Offset(x1.toFloat(), y1Ground.toFloat()), Offset(x1.toFloat(), y1Antenna.toFloat()), strokeWidth = 1.2f)
+ drawCircle(endpointMarker, radius = 3f, center = Offset(x1.toFloat(), y1Antenna.toFloat()))
+
+ drawLine(endpointMarker, Offset(x2.toFloat(), y2Ground.toFloat()), Offset(x2.toFloat(), y2Antenna.toFloat()), strokeWidth = 1.2f)
+ drawCircle(endpointMarker, radius = 3f, center = Offset(x2.toFloat(), y2Antenna.toFloat()))
+
+ val homeLabel = buildEndpointLabel("Home", first.elevationMeters(), homeAntennaHeightMeters)
+ val dxLabel = buildEndpointLabel("DX", last.elevationMeters(), targetAntennaHeightMeters)
+
+ val headerLabelY = plotY - 20.0 // Adjusted for Compose text drawing top-left
+
+ safeDrawText(textMeasurer, homeLabel, topLeft = Offset((plotX + 4.0).toFloat(), headerLabelY.toFloat()), style = textStyle.copy(color = textColor))
+
+ val dxLabelWidth = estimateTextWidth(dxLabel)
+ safeDrawText(textMeasurer, dxLabel, topLeft = Offset(max(plotX + plotWidth - dxLabelWidth - 4.0, plotX + plotWidth * 0.45).toFloat(), headerLabelY.toFloat()), style = textStyle.copy(color = textColor))
+ }
+
+ // Find Point By Sample Index
+ fun findPointBySampleIndex(sampleIndex: Int): PathProfilePoint? {
+ if (sampleIndex < 0) return null
+ return profilePoints.find { it.sampleIndex() == sampleIndex }
+ }
+
+ // Radio Horizon Markers
+ if (totalDistanceKm.isFinite() && totalDistanceKm > 0.0) {
+ val homeHorizonKm = PathGeometryUtils.calculateRadioHorizonDistanceKm(homeAntennaHeightMeters, effectiveEarthRadiusFactor)
+ val targetHorizonKm = PathGeometryUtils.calculateRadioHorizonDistanceKm(targetAntennaHeightMeters, effectiveEarthRadiusFactor)
+
+ fun drawVerticalHorizonMarker(distanceKm: Double, label: String) {
+ val x = plotX + normalizeDistance(distanceKm) * plotWidth
+ drawLine(horizonMarker, Offset(x.toFloat(), plotY.toFloat()), Offset(x.toFloat(), (plotY + plotHeight).toFloat()), strokeWidth = 1f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(4f, 4f)))
+ val fullLabel = String.format(Locale.US, "%s %.1f km", label, distanceKm)
+ val labelWidth = estimateTextWidth(fullLabel)
+ var labelX = x + 5.0
+ if (labelX + labelWidth > plotX + plotWidth) {
+ labelX = max(plotX, x - labelWidth - 5.0)
+ }
+ safeDrawText(textMeasurer, fullLabel, topLeft = Offset(labelX.toFloat(), (plotY + plotHeight - 18.0).toFloat()), style = textStyle.copy(color = textColor))
+ }
+
+ if (homeHorizonKm.isFinite() && homeHorizonKm > 0.0 && homeHorizonKm < totalDistanceKm) {
+ drawVerticalHorizonMarker(homeHorizonKm, "Home radio horizon")
+ }
+ val targetMarkerDistanceKm = totalDistanceKm - targetHorizonKm
+ if (targetMarkerDistanceKm.isFinite() && targetMarkerDistanceKm > 0.0 && targetMarkerDistanceKm < totalDistanceKm) {
+ drawVerticalHorizonMarker(targetMarkerDistanceKm, "DX radio horizon")
+ }
+ }
+
+ // Terrain Horizon Markers
+ if (horizonSummary != null && profilePoints.isNotEmpty()) {
+ fun drawTerrainHorizonMarker(sampleIndex: Int, label: String) {
+ val point = findPointBySampleIndex(sampleIndex) ?: return
+ val x = plotX + normalizeDistance(point.distanceKm()) * plotWidth
+ val y = plotY + plotHeight - normalizeElevation(terrainDisplayElevationMeters(point), minElevation, elevationRange) * plotHeight
+
+ val path = Path()
+ path.moveTo(x.toFloat(), (y - 8.0).toFloat())
+ path.lineTo((x - 5.0).toFloat(), (y + 2.0).toFloat())
+ path.lineTo((x + 5.0).toFloat(), (y + 2.0).toFloat())
+ path.close()
+ drawPath(path, color = terrainHorizonMarker, style = Fill)
+
+ drawLine(terrainHorizonMarker, Offset(x.toFloat(), y.toFloat()), Offset(x.toFloat(), (plotY + plotHeight).toFloat()), strokeWidth = 1f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(3f, 5f)))
+
+ val fullLabel = String.format(Locale.US, "%s %.1f km", label, point.distanceKm())
+ val labelWidth = estimateTextWidth(fullLabel)
+ var labelX = x + 7.0
+ if (labelX + labelWidth > plotX + plotWidth) {
+ labelX = max(plotX, x - labelWidth - 7.0)
+ }
+ var labelY = min(plotY + plotHeight - 22.0, y + 18.0)
+ labelY = max(plotY + 20.0, labelY)
+
+ safeDrawText(textMeasurer, fullLabel, topLeft = Offset(labelX.toFloat(), (labelY - 10.0).toFloat()), style = textStyle.copy(color = textColor))
+ }
+
+ if (horizonSummary.hasHomeTerrainHorizon()) {
+ drawTerrainHorizonMarker(horizonSummary.homeTerrainHorizonSampleIndex(), "Home terrain horizon")
+ }
+ if (horizonSummary.hasTargetTerrainHorizon()) {
+ drawTerrainHorizonMarker(horizonSummary.targetTerrainHorizonSampleIndex(), "DX terrain horizon")
+ }
+ }
+
+ // Obstruction Marker
+ if (obstructionSummary != null && obstructionSummary.hasDominantLosObstruction()) {
+ val point = findPointBySampleIndex(obstructionSummary.dominantObstructionSampleIndex())
+ if (point != null) {
+ val x = plotX + normalizeDistance(point.distanceKm()) * plotWidth
+ val y = plotY + plotHeight - normalizeElevation(terrainDisplayElevationMeters(point), minElevation, elevationRange) * plotHeight
+
+ val path = Path()
+ path.moveTo(x.toFloat(), (y - 7.0).toFloat())
+ path.lineTo((x - 5.0).toFloat(), y.toFloat())
+ path.lineTo(x.toFloat(), (y + 7.0).toFloat())
+ path.lineTo((x + 5.0).toFloat(), y.toFloat())
+ path.close()
+ drawPath(path, color = obstructionMarker, style = Fill)
+
+ drawLine(obstructionMarker, Offset(x.toFloat(), y.toFloat()), Offset(x.toFloat(), (plotY + plotHeight).toFloat()), strokeWidth = 1f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(2f, 4f)))
+
+ val label = String.format(Locale.US, "Diffraction candidate %.1f km, KE ≈ %.1f dB", obstructionSummary.dominantObstructionPathDistanceKm(), obstructionSummary.estimatedKnifeEdgeLossDb())
+ val labelWidth = estimateTextWidth(label)
+ var labelX = x + 8.0
+ if (labelX + labelWidth > plotX + plotWidth) {
+ labelX = max(plotX, x - labelWidth - 8.0)
+ }
+ var labelY = min(plotY + plotHeight - 34.0, y + 30.0)
+ labelY = max(plotY + 34.0, labelY)
+
+ safeDrawText(textMeasurer, label, topLeft = Offset(labelX.toFloat(), (labelY - 10.0).toFloat()), style = textStyle.copy(color = textColor))
+ }
+ }
+
+ // Critical Marker
+ fun findCriticalPoint(): PathProfilePoint? {
+ var worstFresnelPoint: PathProfilePoint? = null
+ var worstFresnelIntrusion = 0.0
+ var worstLosPoint: PathProfilePoint? = null
+ var minimumLosClearance = Double.POSITIVE_INFINITY
+
+ for (point in profilePoints) {
+ if (point.fresnelIntrusionMeters().isFinite() && point.fresnelIntrusionMeters() > worstFresnelIntrusion) {
+ worstFresnelIntrusion = point.fresnelIntrusionMeters()
+ worstFresnelPoint = point
+ }
+ if (point.lineOfSightClearanceMeters().isFinite() && point.lineOfSightClearanceMeters() < minimumLosClearance) {
+ minimumLosClearance = point.lineOfSightClearanceMeters()
+ worstLosPoint = point
+ }
+ }
+ if (worstFresnelPoint != null) return worstFresnelPoint
+ if (worstLosPoint != null && worstLosPoint.isLineOfSightBlocked) return worstLosPoint
+ return null
+ }
+
+ val criticalPoint = findCriticalPoint()
+ if (criticalPoint != null) {
+ val x = plotX + normalizeDistance(criticalPoint.distanceKm()) * plotWidth
+ val y = plotY + plotHeight - normalizeElevation(terrainDisplayElevationMeters(criticalPoint), minElevation, elevationRange) * plotHeight
+
+ drawCircle(criticalMarker, radius = 4f, center = Offset(x.toFloat(), y.toFloat()))
+ drawLine(criticalMarker, Offset(x.toFloat(), y.toFloat()), Offset(x.toFloat(), (plotY + plotHeight).toFloat()), strokeWidth = 1f)
+
+ val label = if (criticalPoint.hasFresnelIntrusion()) {
+ String.format(Locale.US, "Critical point: Fresnel intrusion %.1f m @ %.1f km", criticalPoint.fresnelIntrusionMeters(), criticalPoint.distanceKm())
+ } else {
+ String.format(Locale.US, "Critical point: LOS clearance %.1f m @ %.1f km", criticalPoint.lineOfSightClearanceMeters(), criticalPoint.distanceKm())
+ }
+
+ val labelWidth = estimateTextWidth(label)
+ var labelX = x + 8.0
+ if (labelX + labelWidth > plotX + plotWidth) {
+ labelX = max(plotX, x - labelWidth - 8.0)
+ }
+ var labelY = max(plotY + 22.0, y - 10.0)
+ if (labelY < plotY + 18.0) {
+ labelY = plotY + 18.0
+ }
+
+ safeDrawText(textMeasurer, label, topLeft = Offset(labelX.toFloat(), (labelY - 10.0).toFloat()), style = textStyle.copy(color = textColor))
+ }
+
+ // Draw axis labels
+ drawAxisLabels(minElevation, maxElevation)
+
+ // Draw legend
+ var legX = plotX
+ val legY = headerTop + 12.0
+ val textY = legY - 10.0 // adjust for compose text drawing top-left vs baseline
+
+ safeDrawText(textMeasurer, "Legend:", topLeft = Offset(legX.toFloat(), textY.toFloat()), style = textStyle.copy(color = textColor))
+ legX += 48.0
+
+ fun drawHorizontalLegendLineItem(lineColor: Color, dashed: Boolean, lx: Double, ly: Double, text: String): Double {
+ val pe = if (dashed) PathEffect.dashPathEffect(floatArrayOf(6f, 4f)) else null
+ drawLine(lineColor, Offset(lx.toFloat(), (ly - 4.0).toFloat()), Offset((lx + 14.0).toFloat(), (ly - 4.0).toFloat()), strokeWidth = 1.6f, pathEffect = pe)
+ safeDrawText(textMeasurer, text, topLeft = Offset((lx + 18.0).toFloat(), (ly - 10.0).toFloat()), style = textStyle.copy(color = textColor))
+ return lx + 18.0 + estimateTextWidth(text) + 18.0
+ }
+
+ fun drawHorizontalLegendTriangleItem(markerColor: Color, lx: Double, ly: Double, text: String): Double {
+ val path = Path()
+ path.moveTo((lx + 4.0).toFloat(), (ly - 10.0).toFloat())
+ path.lineTo(lx.toFloat(), (ly - 2.0).toFloat())
+ path.lineTo((lx + 8.0).toFloat(), (ly - 2.0).toFloat())
+ path.close()
+ drawPath(path, color = markerColor, style = Fill)
+ safeDrawText(textMeasurer, text, topLeft = Offset((lx + 14.0).toFloat(), (ly - 10.0).toFloat()), style = textStyle.copy(color = textColor))
+ return lx + 14.0 + estimateTextWidth(text) + 18.0
+ }
+
+ fun drawHorizontalLegendDiamondItem(markerColor: Color, lx: Double, ly: Double, text: String): Double {
+ val cx = lx + 4.0
+ val cy = ly - 6.0
+ val path = Path()
+ path.moveTo(cx.toFloat(), (cy - 4.0).toFloat())
+ path.lineTo((cx - 4.0).toFloat(), cy.toFloat())
+ path.lineTo(cx.toFloat(), (cy + 4.0).toFloat())
+ path.lineTo((cx + 4.0).toFloat(), cy.toFloat())
+ path.close()
+ drawPath(path, color = markerColor, style = Fill)
+ safeDrawText(textMeasurer, text, topLeft = Offset((lx + 14.0).toFloat(), (ly - 10.0).toFloat()), style = textStyle.copy(color = textColor))
+ return lx + 14.0 + estimateTextWidth(text) + 18.0
+ }
+
+ fun drawHorizontalLegendDotItem(markerColor: Color, lx: Double, ly: Double, text: String): Double {
+ drawCircle(markerColor, radius = 4f, center = Offset((lx + 4.0).toFloat(), (ly - 4.0).toFloat()))
+ safeDrawText(textMeasurer, text, topLeft = Offset((lx + 14.0).toFloat(), (ly - 10.0).toFloat()), style = textStyle.copy(color = textColor))
+ return lx + 14.0 + estimateTextWidth(text) + 18.0
+ }
+
+ legX = drawHorizontalLegendLineItem(terrainLine, false, legX, legY, "Terrain")
+ legX = drawHorizontalLegendLineItem(losLine, false, legX, legY, "LOS")
+ legX = drawHorizontalLegendLineItem(fresnelLine, true, legX, legY, "Fresnel")
+ legX = drawHorizontalLegendLineItem(horizonMarker, true, legX, legY, "Radio hor.")
+ legX = drawHorizontalLegendTriangleItem(terrainHorizonMarker, legX, legY, "Terr. hor.")
+ legX = drawHorizontalLegendDiamondItem(obstructionMarker, legX, legY, "Diffraction")
+ drawHorizontalLegendDotItem(criticalMarker, legX, legY, "Critical")
+ }
+}
+
+
+fun androidx.compose.ui.graphics.drawscope.DrawScope.safeDrawText(
+ textMeasurer: androidx.compose.ui.text.TextMeasurer,
+ text: String,
+ topLeft: androidx.compose.ui.geometry.Offset,
+ style: androidx.compose.ui.text.TextStyle
+) {
+ if (topLeft.x.isNaN() || topLeft.y.isNaN() || topLeft.x.isInfinite() || topLeft.y.isInfinite()) return
+ this.drawText(
+ textMeasurer = textMeasurer,
+ text = text,
+ topLeft = topLeft,
+ style = style,
+ size = androidx.compose.ui.geometry.Size(10000f, 10000f)
+ )
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/map/StationMapWindow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/StationMapWindow.kt
new file mode 100644
index 00000000..d6859d47
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/StationMapWindow.kt
@@ -0,0 +1,364 @@
+package kst4contest.view.compose.map
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import kst4contest.model.Band
+import kst4contest.view.ApplicationRuntimeLauncher
+import kst4contest.view.compose.ComposeWindowHost
+import kst4contest.view.compose.MainWindowState
+import kst4contest.view.map.MapCallsignRawSnapshotBuilder
+import kst4contest.view.map.PathAnalysisResult
+import kst4contest.view.map.StationMapStatusText
+import kst4contest.view.map.TileFetcher
+import java.util.EnumSet
+
+@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
+object StationMapWindow {
+
+ private val host = ComposeWindowHost("Map")
+ var isDarkThemeState = androidx.compose.runtime.mutableStateOf(false)
+
+ fun show(
+ mainWindowState: MainWindowState,
+ darkMode: Boolean,
+ baseFontSizeSp: Float,
+ widthDp: Float,
+ heightDp: Float,
+ onResized: (Float, Float) -> Unit,
+ ) {
+ isDarkThemeState.value = darkMode
+ host.show(
+ title = "Station Map",
+ darkMode = darkMode,
+ baseFontSizeSp = baseFontSizeSp,
+ widthDp = widthDp,
+ heightDp = heightDp,
+ onResized = onResized,
+ ) { _ ->
+ val window = this.window
+ androidx.compose.runtime.LaunchedEffect(window) { window?.minimumSize = java.awt.Dimension(900, 600) }
+ val tileFetcher = remember { TileFetcher() }
+
+ /*
+ * Released when the window closes. The fetcher owns a thread pool, and a
+ * live pool thread is a GC root: without this, every open of this window
+ * leaves its whole tile cache reachable for the rest of the session.
+ */
+ androidx.compose.runtime.DisposableEffect(tileFetcher) {
+ onDispose { tileFetcher.close() }
+ }
+
+ /*
+ * The fetcher is plain Java state, so nothing about it recomposes on its
+ * own. Polled rather than wired through a callback: this drives one line of
+ * text, and a callback from a background fetch thread into Compose state is
+ * more machinery than the question deserves.
+ */
+ var tilesUnavailable by remember { mutableStateOf(false) }
+ LaunchedEffect(tileFetcher) {
+ while (true) {
+ kotlinx.coroutines.delay(1_000)
+ tilesUnavailable = tileFetcher.isTileSourceUnavailable()
+ }
+ }
+ val prefs = mainWindowState.prefs
+
+ var groupingEnabled by remember { mutableStateOf(prefs.isGUIstationMapClusteringEnabled) }
+ var pathAnalysisVisible by remember { mutableStateOf(prefs.isGUIstationMapPathAnalysisVisible) }
+
+ val selectedMember = mainWindowState.selectedStation.selected
+
+ val ownLocator6 by remember { androidx.compose.runtime.mutableStateOf(mainWindowState.prefs.stn_loginLocatorMainCat) }
+ val antennaAzimuthDeg = mainWindowState.antennaQtf.value
+ val antennaBeamWidthDeg = mainWindowState.prefs.stn_antennaBeamWidthDeg
+ val maxQrbKm = mainWindowState.prefs.stn_maxQRBDefault
+
+ val markers by remember(mainWindowState.stations.rows, mainWindowState.stations.contentRevision, selectedMember, mainWindowState.activeBands) {
+ derivedStateOf {
+ val builder = MapCallsignRawSnapshotBuilder()
+ val activeBands = mainWindowState.activeBands
+
+ val bandSet = EnumSet.noneOf(Band::class.java)
+ if (activeBands.isNotEmpty()) {
+ bandSet.addAll(activeBands)
+ }
+
+ builder.buildSnapshots(
+ mainWindowState.stations.rows,
+ selectedMember,
+ bandSet
+ )
+ }
+ }
+
+ var pathAnalysisResult by remember { mutableStateOf(null) }
+ val mapState = remember { MapState() }
+
+ LaunchedEffect(selectedMember) {
+ if (selectedMember == null) {
+ pathAnalysisResult = null
+ return@LaunchedEffect
+ }
+
+ val chatController = ApplicationRuntimeLauncher.getCurrent()?.chatController
+ if (chatController != null) {
+ val selectedSnapshot = markers.find { it.callSignRaw() == selectedMember.callSignRaw }
+
+ if (selectedSnapshot != null && selectedSnapshot.hasUsablePosition()) {
+ /*
+ * Pan to the station and leave the zoom alone. The Leaflet map
+ * did exactly this (focusCallsignRaw) and touched the zoom only
+ * when the station was hidden inside a bubble, and then only far
+ * enough to break the bubble open. An earlier Compose version
+ * recomputed a zoom from a bounding box on every selection and
+ * clamped it into 4..12, which threw an operator working at zoom
+ * 15 out to about 7 every time they picked the next station.
+ * AGENTS.md names zoom first among the things not to change as
+ * an incidental side effect.
+ */
+ mapState.centerLon = selectedSnapshot.longitudeDeg()
+ mapState.centerLat = selectedSnapshot.latitudeDeg()
+ mapState.zoom = kst4contest.view.map.StationMapClusterer
+ .zoomToRevealStations(mapState.zoom.toDouble(), groupingEnabled)
+ .toFloat()
+ }
+
+ pathAnalysisResult = PathAnalysisResult.loading(
+ prefs.stn_loginLocatorMainCat,
+ selectedSnapshot?.locator6() ?: "",
+ selectedSnapshot?.callSignRaw() ?: ""
+ )
+
+ val overrideBand: kst4contest.model.Band? = null
+
+ chatController.reachabilityService.requestPathAnalysisForMap(
+ selectedMember,
+ selectedSnapshot,
+ overrideBand
+ ) { result ->
+ pathAnalysisResult = result
+ }
+ }
+ }
+
+ androidx.compose.material3.Surface(
+ color = androidx.compose.material3.MaterialTheme.colorScheme.background,
+ contentColor = androidx.compose.material3.MaterialTheme.colorScheme.onBackground,
+ modifier = Modifier.fillMaxSize()
+ ) {
+ Column(Modifier.fillMaxSize()) {
+ /*
+ * The status line takes the room, so the buttons sit at the right edge
+ * the way the JavaFX header had them (HBox.setHgrow on the label). The
+ * controls are the shared ones: a raw Material button paints itself in
+ * the theme's primary colour, which in the evening sheet is a bright
+ * green, and the map window was the only one wearing it.
+ */
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ val statusText = StationMapStatusText.build(
+ markers.size,
+ mainWindowState.stationFilter.activeFilters.isNotEmpty() ||
+ mainWindowState.stationFilter.searchText.isNotBlank(),
+ markers.firstOrNull { it.callSignRaw() == selectedMember?.callSignRaw },
+ )
+
+ /*
+ * The tail of this line carries the bands and the frequency, and on a
+ * narrow window that is exactly what the ellipsis eats. The JavaFX
+ * header put the same text into a tooltip for that reason
+ * (statusTooltip, StationMapView.updateStatusLabel).
+ */
+ androidx.compose.foundation.TooltipArea(
+ tooltip = { kst4contest.view.compose.TooltipCard(statusText) },
+ modifier = Modifier.weight(1f),
+ ) {
+ Text(
+ text = statusText,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+
+ if (selectedMember != null) {
+ kst4contest.view.compose.Form.button("Trigger cluster spot") {
+ ApplicationRuntimeLauncher.getCurrent()?.chatController
+ ?.dxClusterServer?.broadcastSingleDXClusterEntryToLoggers(selectedMember)
+ }
+ }
+
+ kst4contest.view.compose.Form.button("Reset view") {
+ mainWindowState.selectedStation.select(null)
+ val home = runCatching {
+ kst4contest.locatorUtils.Location(ownLocator6)
+ }.getOrNull()
+ mapState.centerLon = home?.longitude?.toDegrees() ?: 10.0
+ mapState.centerLat = home?.latitude?.toDegrees() ?: 51.0
+ /* The zoom is deliberately left alone; see AGENTS.md, UI behaviour. */
+ }
+
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ kst4contest.view.compose.CompactCheckbox(groupingEnabled) {
+ groupingEnabled = it
+ prefs.isGUIstationMapClusteringEnabled = it
+ }
+ Text("Group nearby stations", modifier = Modifier.padding(start = 4.dp))
+ }
+
+ if (!pathAnalysisVisible) {
+ Text(
+ text = "Path analysis is hidden.",
+ fontStyle = FontStyle.Italic,
+ modifier = Modifier.alpha(0.85f)
+ )
+ }
+
+ kst4contest.view.compose.Form.button(
+ if (pathAnalysisVisible) "Hide path analysis" else "Show path analysis"
+ ) {
+ pathAnalysisVisible = !pathAnalysisVisible
+ prefs.isGUIstationMapPathAnalysisVisible = pathAnalysisVisible
+ }
+ }
+
+ // Main Layout
+ Column(Modifier.weight(1f).fillMaxWidth()) {
+ val mapSplitterState = androidx.compose.runtime.remember {
+ kst4contest.view.compose.SplitterState(
+ paneCount = 2,
+ stored = doubleArrayOf(0.65),
+ save = { }
+ )
+ }
+
+ val leftPane: @Composable () -> Unit = {
+ Column(Modifier.fillMaxHeight()) {
+ val isDarkTheme = isDarkThemeState.value
+ androidx.compose.foundation.layout.Box(Modifier.weight(1f).fillMaxWidth()) {
+ ComposeStationMap(
+ markers = markers,
+ tileFetcher = tileFetcher,
+ mapState = mapState,
+ groupingEnabled = groupingEnabled,
+ selectedMarkerCallsign = selectedMember?.callSignRaw,
+ ownLocator6 = ownLocator6,
+ antennaAzimuthDeg = antennaAzimuthDeg ?: 0.0,
+ antennaBeamWidthDeg = antennaBeamWidthDeg,
+ maxQrbKm = maxQrbKm,
+ darkMode = isDarkTheme,
+ onMarkerClicked = { snapshot ->
+ val chatController = ApplicationRuntimeLauncher.getCurrent()?.chatController
+ val member = chatController?.lst_chatMemberList?.snapshot()?.find { it.callSignRaw == snapshot.callSignRaw() }
+ if (member != null) {
+ mainWindowState.selectedStation.select(member)
+
+ // Mirror old JavaFX handleMapCallsignSelection logic: prefill /cq
+ mainWindowState.chatInput.prepareCq(member.callSign ?: "", forceOverwrite = true)
+ }
+ },
+ modifier = Modifier.fillMaxSize()
+ )
+
+ MapOverlayControls(
+ darkMode = isDarkTheme,
+ tilesUnavailable = tilesUnavailable,
+ onZoomIn = { mapState.zoom = (mapState.zoom + 1f).coerceAtMost(MAX_MAP_ZOOM) },
+ onZoomOut = { mapState.zoom = (mapState.zoom - 1f).coerceAtLeast(MIN_MAP_ZOOM) },
+ )
+ }
+
+ if (pathAnalysisVisible) {
+ val points = pathAnalysisResult?.profilePoints() ?: emptyList()
+ val isDarkTheme = isDarkThemeState.value
+ PathProfileChart(
+ profilePoints = points,
+ totalDistanceKm = pathAnalysisResult?.distanceKm() ?: Double.NaN,
+ darkMode = isDarkTheme,
+ homeAntennaHeightMeters = pathAnalysisResult?.homeAntennaHeightMeters() ?: Double.NaN,
+ targetAntennaHeightMeters = pathAnalysisResult?.targetAntennaHeightMeters() ?: Double.NaN,
+ analysisFrequencyMHz = pathAnalysisResult?.analysisFrequencyMHz() ?: Double.NaN,
+ effectiveEarthRadiusFactor = pathAnalysisResult?.effectiveEarthRadiusFactor() ?: kst4contest.view.map.PathGeometryUtils.DEFAULT_EFFECTIVE_EARTH_RADIUS_FACTOR,
+ horizonSummary = pathAnalysisResult?.horizonSummary() ?: kst4contest.view.map.PathHorizonSummary.empty(),
+ obstructionSummary = pathAnalysisResult?.obstructionSummary() ?: kst4contest.view.map.PathObstructionSummary.empty(),
+ modifier = Modifier.fillMaxWidth().height(210.dp)
+ )
+ }
+ }
+ }
+
+ val rightPane: @Composable () -> Unit = {
+ val scrollState = rememberScrollState()
+ androidx.compose.foundation.layout.Box(Modifier.fillMaxHeight()) {
+ PathAnalysisDetails(
+ result = pathAnalysisResult,
+ darkMode = isDarkThemeState.value,
+ modifier = Modifier.fillMaxHeight()
+ .padding(horizontal = 4.dp)
+ .padding(end = 12.dp)
+ .verticalScroll(scrollState),
+ )
+ androidx.compose.foundation.VerticalScrollbar(
+ modifier = Modifier.align(androidx.compose.ui.Alignment.CenterEnd).fillMaxHeight(),
+ adapter = androidx.compose.foundation.rememberScrollbarAdapter(scrollState)
+ )
+ }
+ }
+
+ if (pathAnalysisVisible) {
+ kst4contest.view.compose.SplitterPane(
+ state = mapSplitterState,
+ orientation = kst4contest.view.compose.SplitterOrientation.HORIZONTAL,
+ panes = listOf(leftPane, rightPane),
+ modifier = Modifier.fillMaxSize()
+ )
+ } else {
+ Box(Modifier.fillMaxSize()) {
+ leftPane()
+ }
+ }
+ } // main layout column
+ } // window column
+ } // Surface
+ } // host.show content
+ }
+
+ @JvmStatic
+ fun applyDarkMode(darkMode: Boolean) {
+ isDarkThemeState.value = darkMode
+ host.applyDarkMode(darkMode)
+ }
+
+ fun hide() {
+ host.close()
+ }
+
+ fun isShowing(): Boolean = host.isOpen
+
+ fun toggle(mainWindowState: MainWindowState) {
+ if (isShowing()) {
+ hide()
+ } else {
+ show(
+ mainWindowState = mainWindowState,
+ darkMode = mainWindowState.prefs.isGUI_darkModeActive,
+ baseFontSizeSp = 12f,
+ widthDp = 1000f,
+ heightDp = 700f,
+ onResized = { w, h -> }
+ )
+ }
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/map/TileBitmapCache.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/TileBitmapCache.kt
new file mode 100644
index 00000000..8732005a
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/TileBitmapCache.kt
@@ -0,0 +1,54 @@
+package kst4contest.view.compose.map
+
+import androidx.compose.ui.graphics.ImageBitmap
+
+/**
+ * The decoded tiles the map draws from, bounded.
+ *
+ * A decoded 256x256 tile costs about 262 KB against roughly 20 KB for the PNG it was
+ * decoded from, so this cache is the expensive one and the one that has to have a limit.
+ * It had none: an evening of panning at zoom 12 to 15 retained hundreds of megabytes of
+ * bitmaps of squares nobody would look at again.
+ *
+ * Least-recently-drawn goes first, because the tiles worth keeping are the ones around
+ * where the operator is looking, and reading a tile to draw it is exactly the signal for
+ * that.
+ *
+ * Written to from a tile-fetch thread and read from the composition thread, so every
+ * access is synchronised. A LinkedHashMap in access order is the eviction policy; it is
+ * not thread-safe on its own, and access order means a plain read mutates it.
+ */
+internal class TileBitmapCache(private val maxEntries: Int = DEFAULT_MAX_ENTRIES) {
+
+ private val entries = object : LinkedHashMap(16, 0.75f, true) {
+ override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean =
+ size > maxEntries
+ }
+
+ val size: Int get() = synchronized(entries) { entries.size }
+
+ operator fun get(key: String): ImageBitmap? = synchronized(entries) { entries[key] }
+
+ fun put(key: String, bitmap: ImageBitmap) {
+ synchronized(entries) {
+ if (maxEntries <= 0) {
+ return
+ }
+ entries[key] = bitmap
+ }
+ }
+
+ fun remove(key: String) {
+ synchronized(entries) { entries.remove(key) }
+ }
+
+ fun contains(key: String): Boolean = synchronized(entries) { entries.containsKey(key) }
+
+ private companion object {
+ /**
+ * Enough for several screens' worth at any zoom, so panning back and forth over
+ * the same area never refetches, and about 130 MB of bitmaps at worst.
+ */
+ const val DEFAULT_MAX_ENTRIES = 512
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/AirscoutTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/AirscoutTab.kt
new file mode 100644
index 00000000..e6fba5c2
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/AirscoutTab.kt
@@ -0,0 +1,92 @@
+package kst4contest.view.compose.tabs
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import kst4contest.view.compose.Form
+
+/**
+ * The Airscout tab: the UDP link to AirScout and how the queried frequency is chosen.
+ *
+ * Labels, defaults and the closing note are the JavaFX ones, including that the server
+ * identifier, the client identifier and the frequency mode take effect at once while a
+ * changed UDP port needs a reconnect.
+ *
+ * The identifier, port and band fields keep what the operator typed and mark it as an
+ * error until it is valid; see AirscoutTabState for why an invalid value must not be
+ * written rather than merely corrected.
+ */
+@Composable
+fun AirscoutTab(state: AirscoutTabState) {
+ /* See LogSynchTab: the facade holds no snapshot state, so writes need a revision. */
+ var revision by remember { mutableStateOf(0) }
+
+ Column(
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ @Suppress("UNUSED_EXPRESSION") revision
+
+ Form.section("AirScout UDP communication") {
+ Form.check(
+ "Enable AirScout UDP integration (AirScout 0.9.9.5 or newer)",
+ state.airScout_asUDPListenerEnabled,
+ ) { state.airScout_asUDPListenerEnabled = it; revision++ }
+
+ GuardedTextField(
+ label = "AirScout server identifier [AS]:",
+ value = state.airScout_asServerNameString,
+ accepts = { AirscoutTabState.isValidIdentifier(it) },
+ onAccepted = { state.airScout_asServerNameString = it; revision++ },
+ )
+
+ GuardedTextField(
+ label = "KST4Contest client identifier [KST]:",
+ value = state.airScout_asClientNameString,
+ accepts = { AirscoutTabState.isValidIdentifier(it) },
+ onAccepted = { state.airScout_asClientNameString = it; revision++ },
+ )
+
+ GuardedTextField(
+ label = "AirScout UDP port [9872] — reconnect after changing:",
+ value = state.airScout_asCommunicationPort.toString(),
+ accepts = { typed ->
+ typed.trim().toIntOrNull()?.let { AirscoutTabState.isValidPort(it) } == true
+ },
+ onAccepted = { state.airScout_asCommunicationPort = it.trim().toInt(); revision++ },
+ )
+ }
+
+ Form.section("Queried frequency") {
+ Form.check(
+ "Select AirScout frequency automatically per station",
+ state.airScout_autoBandSelectionEnabled,
+ ) { state.airScout_autoBandSelectionEnabled = it; revision++ }
+
+ GuardedTextField(
+ label = "Forced AirScout band value [1440000 = 144 MHz]:",
+ value = state.airScout_asBandString,
+ enabled = state.bandValueEditable,
+ accepts = { AirscoutTabState.parseBandValue(it) != null },
+ onAccepted = { state.airScout_asBandString = it; revision++ },
+ )
+
+ Text(
+ "Automatic selection uses the station's current QRG first, then " +
+ "station-name and chat-category evidence. Disable it only to force " +
+ "one protocol value for every station."
+ )
+ }
+
+ Text(
+ "Server identifier, client identifier and frequency mode are applied " +
+ "immediately. Reconnect after changing the UDP port."
+ )
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/AirscoutTabState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/AirscoutTabState.kt
new file mode 100644
index 00000000..36304efe
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/AirscoutTabState.kt
@@ -0,0 +1,111 @@
+package kst4contest.view.compose.tabs
+
+import kst4contest.model.ChatPreferences
+
+/**
+ * The Airscout tab as a typed facade over ChatPreferences.
+ *
+ * Every property writes through immediately and there is deliberately no apply(), the
+ * way the JavaFX controls behaved.
+ *
+ * Three properties are guarded before the write, and the guard is not decoration. The
+ * ChatPreferences setters do not keep the previous value on bad input, they substitute
+ * the factory default: an empty identifier becomes "AS"/"KST", a port outside 1..65535
+ * becomes 9872, a band value that is not a positive number becomes "1440000". The
+ * JavaFX window never let that happen — it validated on focus loss and put the stored
+ * value back into the field. Writing through on every keystroke without the same guard
+ * would silently reset an operator's configuration while he is typing.
+ *
+ * The accepted ranges are the JavaFX ones, unchanged: AirScout ports and the band
+ * value are protocol values and neither the defaults nor the bounds are touched.
+ */
+class AirscoutTabState(private val prefs: ChatPreferences) {
+
+ var airScout_asUDPListenerEnabled: Boolean
+ get() = prefs.isAirScout_asUDPListenerEnabled()
+ set(value) { prefs.setAirScout_asUDPListenerEnabled(value) }
+
+ /** Logical identifier of the target AirScout server; invalid input is ignored. */
+ var airScout_asServerNameString: String
+ get() = prefs.getAirScout_asServerNameString()
+ set(value) {
+ if (isValidIdentifier(value)) {
+ prefs.setAirScout_asServerNameString(value.trim())
+ }
+ }
+
+ /** Identifier of this KST4Contest instance; invalid input is ignored. */
+ var airScout_asClientNameString: String
+ get() = prefs.getAirScout_asClientNameString()
+ set(value) {
+ if (isValidIdentifier(value)) {
+ prefs.setAirScout_asClientNameString(value.trim())
+ }
+ }
+
+ /**
+ * AirScout UDP port, default 9872. Must match the AirScout network settings, and a
+ * change takes effect after a reconnect — the JavaFX tab said so and nothing about
+ * that changes here.
+ */
+ var airScout_asCommunicationPort: Int
+ get() = prefs.getAirScout_asCommunicationPort()
+ set(value) {
+ if (isValidPort(value)) {
+ prefs.setAirScout_asCommunicationPort(value)
+ }
+ }
+
+ /**
+ * Pick the AirScout frequency per station instead of forcing one value. Uses the
+ * station's current QRG first, then station-name and chat-category evidence.
+ */
+ var airScout_autoBandSelectionEnabled: Boolean
+ get() = prefs.isAirScout_autoBandSelectionEnabled()
+ set(value) { prefs.setAirScout_autoBandSelectionEnabled(value) }
+
+ /**
+ * The forced AirScout band value, a protocol number such as 1440000 for 144 MHz.
+ * Stored as the protocol spells it, which is what Long.toString of the parsed
+ * value produced in the JavaFX field.
+ */
+ var airScout_asBandString: String
+ get() = prefs.getAirScout_asBandString()
+ set(value) {
+ parseBandValue(value)?.let { prefs.setAirScout_asBandString(it) }
+ }
+
+ /**
+ * False while the frequency is selected per station: the JavaFX band field was
+ * disabled then, because a forced value is the fallback for exactly that case.
+ */
+ val bandValueEditable: Boolean
+ get() = !airScout_autoBandSelectionEnabled
+
+ companion object {
+
+ /**
+ * AirScout encloses the identifiers in quotation marks, so an empty identifier,
+ * a quotation mark or a line break would produce an invalid protocol message.
+ */
+ @JvmStatic
+ fun isValidIdentifier(candidate: String?): Boolean {
+ val normalized = candidate?.trim() ?: return false
+ return normalized.isNotEmpty() &&
+ !normalized.contains("\"") &&
+ !normalized.contains("\r") &&
+ !normalized.contains("\n")
+ }
+
+ /** The range the JavaFX field accepted. Not narrowed. */
+ @JvmStatic
+ fun isValidPort(candidate: Int): Boolean = candidate in 1..65535
+
+ /** The normalized band value, or null when it is not a positive number. */
+ @JvmStatic
+ fun parseBandValue(candidate: String?): String? {
+ val parsed = candidate?.trim()?.toLongOrNull() ?: return null
+ return if (parsed > 0) parsed.toString() else null
+ }
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/BeaconTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/BeaconTab.kt
new file mode 100644
index 00000000..22935b6d
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/BeaconTab.kt
@@ -0,0 +1,55 @@
+package kst4contest.view.compose.tabs
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import kst4contest.view.compose.Form
+
+/**
+ * The beacon tab: one CQ template per chat category and the interval they share.
+ *
+ * The interval is one field for both categories. The second category keeps its own
+ * stored value for the sake of the configuration file, but the operator never saw
+ * two intervals and setting them apart was never possible.
+ */
+@Composable
+fun BeaconTab(state: BeaconTabState, onRefused: (String) -> Unit) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Form.section("CQ beacon for " + state.mainCategoryName) {
+ Form.check("Send the beacon in this category", state.bcn_beaconsEnabledMainCat) {
+ state.bcn_beaconsEnabledMainCat = it
+ }
+ Form.committed(
+ label = "Beacon message",
+ stored = state.beaconTextMainCat,
+ commit = state::commitBeaconTextMainCat,
+ onRefused = onRefused,
+ )
+ }
+
+ Form.section("CQ beacon for " + state.secondCategoryName) {
+ Form.check("Send the beacon in this category", state.bcn_beaconsEnabledSecondCat) {
+ state.bcn_beaconsEnabledSecondCat = it
+ }
+ Form.committed(
+ label = "Beacon message",
+ stored = state.beaconTextSecondCat,
+ commit = state::commitBeaconTextSecondCat,
+ onRefused = onRefused,
+ )
+ }
+
+ Form.section("Shared interval") {
+ Form.committed(
+ label = "Interval in whole minutes",
+ stored = state.beaconIntervalMinutes.toString(),
+ commit = state::commitBeaconInterval,
+ onRefused = onRefused,
+ )
+ }
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/BeaconTabState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/BeaconTabState.kt
new file mode 100644
index 00000000..905d8ccb
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/BeaconTabState.kt
@@ -0,0 +1,104 @@
+package kst4contest.view.compose.tabs
+
+import kst4contest.controller.ChatController
+import kst4contest.model.ChatPreferences
+
+/**
+ * State of the beacon tab: the two CQ templates and the one interval they share.
+ *
+ * The two commit methods are the ports of `applyBeaconTextSetting` and
+ * `applySharedBeaconInterval`. Both returned nothing and opened an alert; here they
+ * return the refusal text instead, so the tab decides how to show it and a test can
+ * read it.
+ *
+ * @param validateBeaconTemplate throws IllegalArgumentException with the reason,
+ * the contract of `ChatController.validateBeaconTemplate`.
+ * @param restartBeaconTimer called only after the interval actually changed; the
+ * JavaFX code restarted the shared timer there and nowhere else.
+ */
+class BeaconTabState(
+ private val prefs: ChatPreferences,
+ private val validateBeaconTemplate: (String) -> Unit,
+ private val restartBeaconTimer: () -> Unit,
+ val mainCategoryName: String,
+ val secondCategoryName: String,
+) {
+
+ var bcn_beaconsEnabledMainCat: Boolean
+ get() = prefs.isBcn_beaconsEnabledMainCat()
+ set(value) { prefs.setBcn_beaconsEnabledMainCat(value) }
+
+ var bcn_beaconsEnabledSecondCat: Boolean
+ get() = prefs.isBcn_beaconsEnabledSecondCat()
+ set(value) { prefs.setBcn_beaconsEnabledSecondCat(value) }
+
+ val beaconTextMainCat: String
+ get() = prefs.getBcn_beaconTextMainCat()
+
+ val beaconTextSecondCat: String
+ get() = prefs.getBcn_beaconTextSecondCat()
+
+ /**
+ * The interval both categories share, never below the minimum.
+ *
+ * The clamp is not cosmetic: a stored zero would otherwise be displayed as a
+ * valid setting while `ChatController` sends at the minimum anyway
+ * (ChatController:3425). The JavaFX field applied the same `Math.max` when it
+ * restored the value after a rejected entry.
+ */
+ val beaconIntervalMinutes: Int
+ get() = maxOf(
+ ChatController.MIN_BEACON_INTERVAL_MINUTES,
+ prefs.getBcn_beaconIntervalInMinutesMainCat(),
+ )
+
+ /** @return null when stored, otherwise the reason it was refused. */
+ fun commitBeaconTextMainCat(template: String): String? =
+ commitBeaconText(template) { prefs.setBcn_beaconTextMainCat(it) }
+
+ /** @return null when stored, otherwise the reason it was refused. */
+ fun commitBeaconTextSecondCat(template: String): String? =
+ commitBeaconText(template) { prefs.setBcn_beaconTextSecondCat(it) }
+
+ private fun commitBeaconText(template: String, store: (String) -> Unit): String? {
+ try {
+ validateBeaconTemplate(template)
+ } catch (refusal: IllegalArgumentException) {
+ /*
+ * The stored template stands. MYCALL and MYQRG are resolved by the timer,
+ * so a refused template that replaced a working one would silently stop
+ * the beacon instead of reporting anything.
+ */
+ return "The beacon message is invalid: " + refusal.message
+ }
+
+ store(template)
+ return null
+ }
+
+ /**
+ * Applies the shared interval to both categories and restarts the timer.
+ *
+ * @return null when applied, otherwise the reason it was refused.
+ */
+ fun commitBeaconInterval(entered: String): String? {
+ val minutes = entered.trim().toIntOrNull()
+
+ if (minutes == null || minutes < ChatController.MIN_BEACON_INTERVAL_MINUTES) {
+ return "Enter a whole beacon interval of at least " +
+ ChatController.MIN_BEACON_INTERVAL_MINUTES + " minute."
+ }
+
+ prefs.setBcn_beaconIntervalInMinutesMainCat(minutes)
+
+ /*
+ * The second-category value is legacy XML which nothing reads any more, but
+ * it is written to keep the two in step, exactly as applySharedBeaconInterval
+ * did.
+ */
+ prefs.setBcn_beaconIntervalInMinutesSecondCat(minutes)
+
+ restartBeaconTimer()
+ return null
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ExternalInterfaceFields.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ExternalInterfaceFields.kt
new file mode 100644
index 00000000..263a8660
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ExternalInterfaceFields.kt
@@ -0,0 +1,156 @@
+package kst4contest.view.compose.tabs
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.width
+import androidx.compose.material3.MaterialTheme
+import kst4contest.view.compose.CompactTextField
+import kst4contest.view.compose.Density
+import kst4contest.view.compose.Form
+import androidx.compose.material3.Button
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.onFocusChanged
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.AwtWindow
+import java.awt.FileDialog
+import java.awt.Frame
+import java.io.File
+
+/**
+ * Controls the three externally-facing settings tabs need and the shared form controls
+ * do not provide.
+ *
+ * Both exist because of the JavaFX behaviour they replace, not for decoration:
+ *
+ * - [GuardedTextField] keeps the text the operator typed and only hands up values the
+ * caller accepts. The JavaFX fields validated on focus loss and put the stored value
+ * back on bad input. Writing through on every keystroke without that guard would be
+ * worse than the old behaviour, because the ChatPreferences setters for the AirScout
+ * identifiers, the AirScout port and the AirScout band value substitute the factory
+ * default on bad input instead of keeping the previous value. It also reports focus
+ * loss, which is where a port change may rebind a socket.
+ * - [FilePathRow] is the read-only path plus "Choose..." button of the Log synch tab.
+ */
+@Composable
+internal fun GuardedTextField(
+ label: String,
+ value: String,
+ enabled: Boolean = true,
+ accepts: (String) -> Boolean = { true },
+ onAccepted: (String) -> Unit,
+ onCommit: () -> Unit = {},
+) {
+ /*
+ * Keyed on the stored value: the draft is only reset when the stored value really
+ * changed. A keystroke that is rejected, or one that round-trips to the same stored
+ * value such as a trailing space that is trimmed away, leaves the typed text alone.
+ */
+ var draft by remember(value) { mutableStateOf(value) }
+ var hadFocus by remember { mutableStateOf(false) }
+
+ LabelledRow(label) {
+ CompactTextField(
+ value = draft,
+ onValueChange = { typed ->
+ draft = typed
+ if (accepts(typed)) {
+ onAccepted(typed)
+ }
+ },
+ enabled = enabled,
+ isError = !accepts(draft),
+ modifier = Modifier.fillMaxWidth().onFocusChanged { focusState ->
+ if (hadFocus && !focusState.isFocused) {
+ onCommit()
+ }
+ hadFocus = focusState.isFocused
+ },
+ )
+ }
+}
+
+/**
+ * A stored file path the operator changes through a file chooser only, the way the
+ * JavaFX tab did: it showed a Label, never an editable field, so a typed path was never
+ * possible there and is not introduced here.
+ */
+@Composable
+internal fun FilePathRow(
+ label: String,
+ value: String,
+ chooserTitle: String,
+ onChosen: (String) -> Unit,
+) {
+ var choosing by remember { mutableStateOf(false) }
+
+ LabelledRow(label) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth(),
+ ) {
+ Text(
+ value,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.weight(1f),
+ )
+ Form.button("Choose...") { choosing = true }
+ }
+ }
+
+ if (choosing) {
+ FileChooserDialog(chooserTitle) { chosenPath ->
+ choosing = false
+ if (chosenPath != null) {
+ onChosen(chosenPath)
+ }
+ }
+ }
+}
+
+/**
+ * The AWT file dialog, opened from Compose. The JavaFX chooser started in the user's
+ * home directory and a cancelled choice kept the stored path — both are preserved.
+ */
+@Composable
+private fun FileChooserDialog(title: String, onResult: (String?) -> Unit) {
+ AwtWindow(
+ create = {
+ object : FileDialog(null as Frame?, title, LOAD) {
+ override fun setVisible(visible: Boolean) {
+ if (visible) {
+ directory = System.getProperty("user.home")
+ }
+ super.setVisible(visible)
+ if (visible) {
+ val chosenName = file
+ onResult(
+ if (chosenName == null) null
+ else File(directory ?: "", chosenName).absolutePath
+ )
+ }
+ }
+ }
+ },
+ dispose = FileDialog::dispose,
+ )
+}
+
+/** The label column width the shared form controls use, so the tabs line up. */
+@Composable
+private fun LabelledRow(label: String, field: @Composable () -> Unit) {
+ /*
+ * Delegates rather than repeating the row: the label column width and the row height
+ * are defined once, in Form, so this file cannot drift away from the other tabs.
+ */
+ Form.labelledRow(label, field)
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/GuiOptionsTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/GuiOptionsTab.kt
new file mode 100644
index 00000000..c476ab5a
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/GuiOptionsTab.kt
@@ -0,0 +1,53 @@
+package kst4contest.view.compose.tabs
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import kst4contest.view.compose.Form
+
+/**
+ * The GUI tab: startup design, the startup message filters and the band-column hints.
+ *
+ * The four startup filters stay four independent checkboxes. The JavaFX tab offered
+ * them that way, and turning them into a choice of one would take a combination away
+ * that an operator may have configured.
+ */
+@Composable
+fun GuiOptionsTab(state: GuiOptionsTabState) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Form.section("Startup design") {
+ Form.check("Start in the evening design", state.gUI_darkModeActiveByDefault) {
+ state.gUI_darkModeActiveByDefault = it
+ }
+ }
+
+ Form.flowingSection("Message filters active at startup") {
+ Form.check("Public messages", state.guiOptions_defaultFilterPublicMsgs) {
+ state.guiOptions_defaultFilterPublicMsgs = it
+ }
+ Form.check("Private messages to me", state.guiOptions_defaultFilterPmToMe) {
+ state.guiOptions_defaultFilterPmToMe = it
+ }
+ Form.check("Private messages to others", state.guiOptions_defaultFilterPmToOther) {
+ state.guiOptions_defaultFilterPmToOther = it
+ }
+ Form.check("No filter", state.guiOptions_defaultFilterNothing) {
+ state.guiOptions_defaultFilterNothing = it
+ }
+ }
+
+ Form.flowingSection("Hints in the band columns") {
+ Form.check("Mark a callsign seen for the first time", state.guiOptions_showFreshCallHintInBandColumns) {
+ state.guiOptions_showFreshCallHintInBandColumns = it
+ }
+ Form.check("Mark a worked large grid field", state.guiOptions_showGrossFieldWorkedHintInBandColumns) {
+ state.guiOptions_showGrossFieldWorkedHintInBandColumns = it
+ }
+ }
+
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/GuiOptionsTabState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/GuiOptionsTabState.kt
new file mode 100644
index 00000000..40c10802
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/GuiOptionsTabState.kt
@@ -0,0 +1,41 @@
+package kst4contest.view.compose.tabs
+
+import kst4contest.model.ChatPreferences
+
+/**
+ * The GUI tab as a typed facade over ChatPreferences.
+ *
+ * Writes through immediately and has no apply(), like every settings tab: the
+ * JavaFX controls wrote as they changed and "Save settings" only persisted.
+ */
+class GuiOptionsTabState(private val prefs: ChatPreferences) {
+
+ var gUI_darkModeActiveByDefault: Boolean
+ get() = prefs.isGUI_darkModeActiveByDefault()
+ set(value) { prefs.setGUI_darkModeActiveByDefault(value) }
+
+ var guiOptions_defaultFilterNothing: Boolean
+ get() = prefs.isGuiOptions_defaultFilterNothing()
+ set(value) { prefs.setGuiOptions_defaultFilterNothing(value) }
+
+ var guiOptions_defaultFilterPmToMe: Boolean
+ get() = prefs.isGuiOptions_defaultFilterPmToMe()
+ set(value) { prefs.setGuiOptions_defaultFilterPmToMe(value) }
+
+ var guiOptions_defaultFilterPmToOther: Boolean
+ get() = prefs.isGuiOptions_defaultFilterPmToOther()
+ set(value) { prefs.setGuiOptions_defaultFilterPmToOther(value) }
+
+ var guiOptions_defaultFilterPublicMsgs: Boolean
+ get() = prefs.isGuiOptions_defaultFilterPublicMsgs()
+ set(value) { prefs.setGuiOptions_defaultFilterPublicMsgs(value) }
+
+ var guiOptions_showFreshCallHintInBandColumns: Boolean
+ get() = prefs.isGuiOptions_showFreshCallHintInBandColumns()
+ set(value) { prefs.setGuiOptions_showFreshCallHintInBandColumns(value) }
+
+ var guiOptions_showGrossFieldWorkedHintInBandColumns: Boolean
+ get() = prefs.isGuiOptions_showGrossFieldWorkedHintInBandColumns()
+ set(value) { prefs.setGuiOptions_showGrossFieldWorkedHintInBandColumns(value) }
+
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/LogSynchTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/LogSynchTab.kt
new file mode 100644
index 00000000..f9da9486
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/LogSynchTab.kt
@@ -0,0 +1,99 @@
+package kst4contest.view.compose.tabs
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import kst4contest.view.compose.Form
+
+/**
+ * The Log synch tab: how worked callsigns reach KST4Contest — by polling a log file, by
+ * UDP from N1MM+/QARTEST/UCXLog/DXLog, and by the Win-Test network.
+ *
+ * No confirmation callback: LogSynchTabState writes through to ChatPreferences, the way
+ * the JavaFX controls did. "Save settings" persists, it does not collect.
+ *
+ * Labels, units and default hints are the JavaFX ones. The Win-Test port field is
+ * disabled while its listener is off, as it was there.
+ */
+@Composable
+fun LogSynchTab(state: LogSynchTabState) {
+ /*
+ * The state is a plain facade over ChatPreferences with no snapshot state in it, so
+ * a control would keep showing the old value after its own write. Counting revisions
+ * is the device WorkedDatabaseTab uses for the same reason.
+ */
+ var revision by remember { mutableStateOf(0) }
+
+ Column(
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ @Suppress("UNUSED_EXPRESSION") revision
+
+ Form.section("File polling for worked callsigns") {
+ Form.check(
+ "Read worked callsigns periodically from a log file (without band information)",
+ state.logsynch_fileBasedWkdCallInterpreterEnabled,
+ ) { state.logsynch_fileBasedWkdCallInterpreterEnabled = it; revision++ }
+
+ FilePathRow(
+ label = "Log file to be monitored:",
+ value = state.logsynch_fileBasedWkdCallInterpreterFileNameReadOnly,
+ chooserTitle = "Choose Readonly-Loginterpreter-File",
+ ) { state.logsynch_fileBasedWkdCallInterpreterFileNameReadOnly = it; revision++ }
+ }
+
+ Form.section("Network-based QSO synchronization (N1MM/QARTEST/UCXLog/DXLog)") {
+ Form.check(
+ "Process QSO messages from N1MM+, QARTEST, UCXLog and DXLog.net",
+ state.logsynch_ucxUDPWkdCallListenerEnabled,
+ ) { state.logsynch_ucxUDPWkdCallListenerEnabled = it; revision++ }
+
+ Form.int(
+ "Shared UDP port for QSO and TRX messages [default 12060]:",
+ state.logsynch_ucxUDPWkdCallListenerPort,
+ ) { state.logsynch_ucxUDPWkdCallListenerPort = it; revision++ }
+ }
+
+ Form.section("Win-Test Network-Listener") {
+ Form.check(
+ "Receive Win-Test network based UDP log messages",
+ state.logsynch_wintestNetworkListenerEnabled,
+ ) { state.logsynch_wintestNetworkListenerEnabled = it; revision++ }
+
+ GuardedTextField(
+ label = "UDP-Port for Win-Test listener (default is 9871)",
+ value = state.logsynch_wintestNetworkPort.toString(),
+ enabled = state.logsynch_wintestNetworkListenerEnabled,
+ accepts = { it.trim().toIntOrNull() != null },
+ onAccepted = { state.logsynch_wintestNetworkPort = it.trim().toInt(); revision++ },
+ // Rebinding the socket belongs to the finished entry, not to every digit.
+ onCommit = { state.commitWintestNetworkPort() },
+ )
+
+ GuardedTextField(
+ label = "KST station name in Win-Test network (src of SKED packets)",
+ value = state.logsynch_wintestNetworkStationNameOfKST,
+ onAccepted = { state.logsynch_wintestNetworkStationNameOfKST = it; revision++ },
+ )
+
+ GuardedTextField(
+ label = "UDP broadcast address for Win-Test (default = internet interface broadcast)",
+ value = state.logsynch_wintestNetworkBroadcastAddress,
+ onAccepted = { state.logsynch_wintestNetworkBroadcastAddress = it; revision++ },
+ )
+
+ Text(
+ "The broadcast address is detected once while it is still " +
+ "${LogSynchTabState.DEFAULT_BROADCAST_ADDRESS}. A port change takes " +
+ "effect on the running listener when the field loses focus."
+ )
+ }
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/LogSynchTabState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/LogSynchTabState.kt
new file mode 100644
index 00000000..0bfdff49
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/LogSynchTabState.kt
@@ -0,0 +1,127 @@
+package kst4contest.view.compose.tabs
+
+import kst4contest.model.ChatPreferences
+
+/**
+ * The Log synch tab as a typed facade over ChatPreferences.
+ *
+ * Every property writes through immediately, and there is deliberately no apply().
+ * That mirrors the JavaFX window it replaces: its controls each wrote into
+ * ChatPreferences the moment they changed, and its two buttons only persisted
+ * ("Save settings" calls writePreferencesToXmlFile) or closed the window.
+ *
+ * Two things this tab controlled are not settings at all but effects on the running
+ * session, and no coverage test can see them because they are not ChatPreferences
+ * setters. They are taken as callbacks rather than from a ChatController so the rules
+ * stay testable without opening a database:
+ *
+ * - the Win-Test UDP listener is started and stopped with its setting;
+ * - the Win-Test broadcast address is auto-detected while it is still the catch-all
+ * default, which the JavaFX window did while building the settings pane.
+ *
+ * Ports, transports and framing are untouched: the accepted values, the defaults and
+ * the moment a socket is rebound are the ones the JavaFX window had.
+ *
+ * @param restartWintestListener rebinds the Win-Test UDP listener; maps to
+ * ChatController.restartWintestUdpListenerIfEnabled()
+ * @param stopWintestListener maps to ChatController.stopWintestUdpListener()
+ * @param detectWintestBroadcastAddress the detected subnet broadcast address, or null
+ * when none could be determined; maps to detectPreferredWintestBroadcastAddress()
+ */
+class LogSynchTabState(
+ private val prefs: ChatPreferences,
+ private val restartWintestListener: () -> Unit,
+ private val stopWintestListener: () -> Unit,
+ private val detectWintestBroadcastAddress: () -> String?,
+) {
+
+ init {
+ autoDetectBroadcastAddressWhileStillDefault()
+ }
+
+ var logsynch_fileBasedWkdCallInterpreterEnabled: Boolean
+ get() = prefs.isLogsynch_fileBasedWkdCallInterpreterEnabled()
+ set(value) { prefs.setLogsynch_fileBasedWkdCallInterpreterEnabled(value) }
+
+ /**
+ * The log file polled for worked callsigns. The JavaFX tab showed it read-only and
+ * changed it through a file chooser only, so the Compose tab does the same: a typed
+ * path could not be checked for existence there either, and letting one be typed
+ * would be a new capability, not a ported one.
+ */
+ var logsynch_fileBasedWkdCallInterpreterFileNameReadOnly: String
+ get() = prefs.getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly()
+ set(value) { prefs.setLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly(value) }
+
+ var logsynch_ucxUDPWkdCallListenerEnabled: Boolean
+ get() = prefs.isLogsynch_ucxUDPWkdCallListenerEnabled()
+ set(value) { prefs.setLogsynch_ucxUDPWkdCallListenerEnabled(value) }
+
+ /**
+ * Shared UDP port for QSO and TRX messages, default 12060. The JavaFX field
+ * accepted any number here and rebound nothing — that listener comes up at start —
+ * so neither a range nor a restart is added.
+ */
+ var logsynch_ucxUDPWkdCallListenerPort: Int
+ get() = prefs.getLogsynch_ucxUDPWkdCallListenerPort()
+ set(value) { prefs.setLogsynch_ucxUDPWkdCallListenerPort(value) }
+
+ var logsynch_wintestNetworkListenerEnabled: Boolean
+ get() = prefs.isLogsynch_wintestNetworkListenerEnabled()
+ set(value) {
+ prefs.setLogsynch_wintestNetworkListenerEnabled(value)
+ if (value) restartWintestListener() else stopWintestListener()
+ }
+
+ /**
+ * UDP port for the Win-Test listener, default 9871. The setting is written at once
+ * like every other, but the socket is only rebound by [commitWintestNetworkPort] —
+ * the JavaFX field did that on focus loss, and rebinding per keystroke would bind
+ * and unbind a UDP port for every digit on the way to the intended one.
+ */
+ var logsynch_wintestNetworkPort: Int
+ get() = prefs.getLogsynch_wintestNetworkPort()
+ set(value) { prefs.setLogsynch_wintestNetworkPort(value) }
+
+ /** Applies the entered port to the running listener, if there is one. */
+ fun commitWintestNetworkPort() {
+ if (prefs.isLogsynch_wintestNetworkListenerEnabled()) {
+ restartWintestListener()
+ }
+ }
+
+ /** The name KST4Contest uses as source of Win-Test SKED packets. */
+ var logsynch_wintestNetworkStationNameOfKST: String
+ get() = prefs.getLogsynch_wintestNetworkStationNameOfKST()
+ set(value) { prefs.setLogsynch_wintestNetworkStationNameOfKST(value.trim()) }
+
+ /** UDP broadcast address used for sending to Win-Test. */
+ var logsynch_wintestNetworkBroadcastAddress: String
+ get() = prefs.getLogsynch_wintestNetworkBroadcastAddress()
+ set(value) { prefs.setLogsynch_wintestNetworkBroadcastAddress(value.trim()) }
+
+ /**
+ * Replaces the catch-all default 255.255.255.255 with the detected subnet
+ * broadcast address. An address the operator or a previous detection chose is
+ * left alone, and a failed detection keeps the default: guessing an address here
+ * would send SKED packets somewhere nobody is listening.
+ */
+ private fun autoDetectBroadcastAddressWhileStillDefault() {
+ if (prefs.getLogsynch_wintestNetworkBroadcastAddress() != DEFAULT_BROADCAST_ADDRESS) {
+ return
+ }
+ val detected = try {
+ detectWintestBroadcastAddress()
+ } catch (probeFailure: RuntimeException) {
+ null
+ }
+ if (detected != null && detected.isNotBlank()) {
+ prefs.setLogsynch_wintestNetworkBroadcastAddress(detected)
+ }
+ }
+
+ companion object {
+ /** The stored value that still means "not configured for this network". */
+ const val DEFAULT_BROADCAST_ADDRESS: String = "255.255.255.255"
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/MessageHandlingTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/MessageHandlingTab.kt
new file mode 100644
index 00000000..adba5f38
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/MessageHandlingTab.kt
@@ -0,0 +1,30 @@
+package kst4contest.view.compose.tabs
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import kst4contest.view.compose.Form
+
+/** The message-handling tab: the automatic answer and the automatic QRG reply. */
+@Composable
+fun MessageHandlingTab(state: MessageHandlingTabState) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Form.section("Automatic answer to private messages") {
+ Form.check("Answer private messages automatically", state.autoAnswerEnabled) {
+ state.autoAnswerEnabled = it
+ }
+ Form.text("Answer text", state.autoAnswerText) { state.autoAnswerText = it }
+ }
+
+ Form.section("Automatic answer to a QRG request") {
+ Form.check(
+ "Answer a QRG request with the current frequency",
+ state.autoAnswerToQRGRequestEnabled,
+ ) { state.autoAnswerToQRGRequestEnabled = it }
+ }
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/MessageHandlingTabState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/MessageHandlingTabState.kt
new file mode 100644
index 00000000..baf604fe
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/MessageHandlingTabState.kt
@@ -0,0 +1,36 @@
+package kst4contest.view.compose.tabs
+
+import kst4contest.model.ChatPreferences
+
+/**
+ * State of the message-handling tab.
+ *
+ * The JavaFX window offered one checkbox and one text field for the automatic
+ * answer and wrote both chat categories from each of them
+ * (Kst4ContestApplication:12004 and :12039). Exposing one property per pair keeps
+ * that: a tab that wrote only the main category would leave the second chat on its
+ * previous value, which no part of the window ever displayed.
+ */
+class MessageHandlingTabState(private val prefs: ChatPreferences) {
+
+ /** Drives both category flags, as the single JavaFX checkbox did. */
+ var autoAnswerEnabled: Boolean
+ get() = prefs.isMessageHandling_autoAnswerEnabled()
+ set(value) {
+ prefs.setMessageHandling_autoAnswerEnabled(value)
+ prefs.setMessageHandling_autoAnswerEnabledSecondCat(value)
+ }
+
+ /** Drives both category texts, as the single JavaFX text field did. */
+ var autoAnswerText: String
+ get() = prefs.getMessageHandling_autoAnswerTextMainCat()
+ set(value) {
+ prefs.setMessageHandling_autoAnswerTextMainCat(value)
+ prefs.setMessageHandling_autoAnswerTextSecondCat(value)
+ }
+
+ /** Independent of the automatic answer; its own checkbox in the JavaFX window. */
+ var autoAnswerToQRGRequestEnabled: Boolean
+ get() = prefs.isMessageHandling_autoAnswerToQRGRequestEnabled()
+ set(value) { prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(value) }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/NotificationTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/NotificationTab.kt
new file mode 100644
index 00000000..ac7bdbf4
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/NotificationTab.kt
@@ -0,0 +1,148 @@
+package kst4contest.view.compose.tabs
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import kst4contest.model.Band
+import kst4contest.view.compose.DataTable
+import kst4contest.view.compose.Form
+
+/**
+ * The notification tab: sounds, band-upgrade hints, the local DX Cluster server and
+ * the QSO monitoring list.
+ *
+ * The port and the spotter callsign are committed when the field is left, not while
+ * it is typed: both are validated, and a rule applied per keystroke would refuse
+ * every prefix of a valid entry.
+ */
+@Composable
+fun NotificationTab(state: NotificationTabState, onRefused: (String) -> Unit) {
+ var askForCallSign by remember { mutableStateOf(false) }
+
+ Column(
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Form.section("Sounds") {
+ Form.check("Play simple sounds", state.notify_playSimpleSounds) {
+ state.notify_playSimpleSounds = it
+ }
+ Form.check(
+ "Play the callsign in CW on a received private message",
+ state.notify_playCWCallsignsOnRxedPMs,
+ ) { state.notify_playCWCallsignsOnRxedPMs = it }
+ Form.check(
+ "Speak the callsign on a received private message",
+ state.notify_playVoiceCallsignsOnRxedPMs,
+ ) { state.notify_playVoiceCallsignsOnRxedPMs = it }
+ }
+
+ Form.section("Band upgrade hints") {
+ Form.check(
+ "Hint at a band upgrade when a QSO is logged",
+ state.notify_bandUpgradeHintOnLogEnabled,
+ ) { state.notify_bandUpgradeHintOnLogEnabled = it }
+ Form.check(
+ "Raise the priority of a station worth upgrading",
+ state.notify_bandUpgradePriorityBoostEnabled,
+ ) { state.notify_bandUpgradePriorityBoostEnabled = it }
+ }
+
+ Form.section("Local DX Cluster server") {
+ Form.check("Run the DX Cluster server", state.notify_dxClusterServerEnabled) {
+ state.enableDxClusterServer(it)
+ }
+ Form.committed(
+ label = "TCP port",
+ stored = state.notify_dxclusterServerPort.toString(),
+ commit = state::commitDxClusterServerPort,
+ onRefused = onRefused,
+ )
+ Form.committed(
+ label = "Spotter callsign",
+ stored = state.notify_DXCSrv_SpottersCallSign,
+ commit = state::commitSpotterCallSign,
+ onRefused = onRefused,
+ )
+ /*
+ * Shows the band prefix, as the JavaFX ComboBox did: the prefix is what the
+ * stored setting contains.
+ */
+ Form.choice(
+ label = "Fallback band for a spot without band information",
+ items = Band.entries,
+ selected = state.notify_optionalFrequencyPrefix,
+ describe = { it.prefix + " MHz" },
+ onSelect = { state.notify_optionalFrequencyPrefix = it },
+ )
+ Row {
+ /*
+ * A refusal is the normal outcome here: the JavaFX button reported
+ * "no logger connected" the same way, which is exactly what the
+ * operator is testing for.
+ */
+ Form.button("Send a test spot") { state.sendTestSpot()?.let(onRefused) }
+ }
+ }
+
+ Form.section("QSO monitoring") {
+ DataTable(
+ state = state.monitoredCallSigns,
+ addButtonText = "Add monitored callsign",
+ newEntryTemplate = "",
+ onChanged = state::commitMonitoredCallSigns,
+ onRefused = onRefused,
+ /*
+ * The JavaFX button asked for the callsign in a dialog before adding
+ * it, so there was never a placeholder row to edit.
+ */
+ onAdd = { askForCallSign = true },
+ )
+ }
+ }
+
+ if (askForCallSign) {
+ var entered by remember { mutableStateOf("") }
+
+ AlertDialog(
+ onDismissRequest = { askForCallSign = false },
+ title = { Text("QSO monitoring") },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("Add a callsign to the monitoring list")
+ OutlinedTextField(
+ value = entered,
+ onValueChange = { entered = it },
+ singleLine = true,
+ label = { Text("Callsign") },
+ )
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = {
+ askForCallSign = false
+ state.addMonitoredCallSign(entered)?.let(onRefused)
+ }) { Text("OK") }
+ },
+ dismissButton = {
+ TextButton(onClick = { askForCallSign = false }) { Text("Cancel") }
+ },
+ )
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/NotificationTabState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/NotificationTabState.kt
new file mode 100644
index 00000000..9deb9fd4
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/NotificationTabState.kt
@@ -0,0 +1,214 @@
+package kst4contest.view.compose.tabs
+
+import kst4contest.model.Band
+import kst4contest.model.ChatMember
+import kst4contest.model.ChatPreferences
+import kst4contest.observe.SimpleRoster
+import kst4contest.observe.SimpleValue
+import kst4contest.view.GuiUtils
+import kst4contest.view.compose.CommitOutcome
+import kst4contest.view.compose.EditableListState
+import kst4contest.view.compose.EntryRule
+import java.util.Locale
+
+/**
+ * State of the notification tab: sounds, band-upgrade hints, the local DX Cluster
+ * server and the QSO monitoring list.
+ *
+ * Three of these reach past the preferences into the running session, which the
+ * settings coverage test cannot see, so they are constructor callbacks rather than
+ * plain properties: starting and stopping the server, restarting it after a port
+ * change, and sending the test spot.
+ *
+ * @param monitoredRoster `ChatController.getLstNotify_QSOSniffer_sniffedCallSignList`;
+ * it lives on the controller, not in the preferences, which is why it is
+ * passed separately.
+ * @param applyDxClusterServerEnabled starts or stops the server, the work the
+ * JavaFX checkbox did beyond writing the flag.
+ * @param applyDxClusterServerPort restarts the server; called only when the port
+ * actually changed, as the JavaFX field did.
+ * @param broadcastTestSpot delivers the spot and returns null on success or the
+ * reason it could not be delivered.
+ */
+class NotificationTabState(
+ private val prefs: ChatPreferences,
+ private val monitoredRoster: SimpleRoster,
+ private val applyDxClusterServerEnabled: (Boolean) -> Unit,
+ private val applyDxClusterServerPort: (Int) -> Unit,
+ private val broadcastTestSpot: (ChatMember) -> String?,
+) {
+
+ var notify_playSimpleSounds: Boolean
+ get() = prefs.isNotify_playSimpleSounds()
+ set(value) { prefs.setNotify_playSimpleSounds(value) }
+
+ var notify_playCWCallsignsOnRxedPMs: Boolean
+ get() = prefs.isNotify_playCWCallsignsOnRxedPMs()
+ set(value) { prefs.setNotify_playCWCallsignsOnRxedPMs(value) }
+
+ var notify_playVoiceCallsignsOnRxedPMs: Boolean
+ get() = prefs.isNotify_playVoiceCallsignsOnRxedPMs()
+ set(value) { prefs.setNotify_playVoiceCallsignsOnRxedPMs(value) }
+
+ var notify_bandUpgradeHintOnLogEnabled: Boolean
+ get() = prefs.isNotify_bandUpgradeHintOnLogEnabled()
+ set(value) { prefs.setNotify_bandUpgradeHintOnLogEnabled(value) }
+
+ var notify_bandUpgradePriorityBoostEnabled: Boolean
+ get() = prefs.isNotify_bandUpgradePriorityBoostEnabled()
+ set(value) { prefs.setNotify_bandUpgradePriorityBoostEnabled(value) }
+
+ val notify_dxClusterServerEnabled: Boolean
+ get() = prefs.isNotify_dxClusterServerEnabled()
+
+ val notify_dxclusterServerPort: Int
+ get() = prefs.getNotify_dxclusterServerPort()
+
+ val notify_DXCSrv_SpottersCallSign: String
+ get() = prefs.getNotify_DXCSrv_SpottersCallSign().get()
+
+ /**
+ * The band a spot falls back to when its frequency carries no band information.
+ *
+ * Stored as a prefix string, so an unreadable value is possible; reading it
+ * repairs the stored value to 144, which is what the JavaFX window did when it
+ * built the combo box (Kst4ContestApplication:11349).
+ */
+ var notify_optionalFrequencyPrefix: Band
+ get() {
+ val stored = Band.fromPrefix(prefs.getNotify_optionalFrequencyPrefix().get())
+
+ if (stored == null) {
+ prefs.setNotify_optionalFrequencyPrefix(Band.B_144.prefix)
+ return Band.B_144
+ }
+
+ return stored
+ }
+ set(value) { prefs.setNotify_optionalFrequencyPrefix(value.prefix) }
+
+ val monitoredCallSigns = EditableListState(
+ monitoredRoster.snapshot(),
+ rule = EntryRule { candidate, _, others -> judgeMonitoredCallSign(candidate, others) },
+ )
+
+ /** Writes the flag and lets the running session start or stop the server. */
+ fun enableDxClusterServer(enabled: Boolean) {
+ prefs.setNotify_dxClusterServerEnabled(enabled)
+ applyDxClusterServerEnabled(enabled)
+ }
+
+ /**
+ * @return null when applied, otherwise the reason the port was refused.
+ */
+ fun commitDxClusterServerPort(entered: String): String? {
+ val previousPort = prefs.getNotify_dxclusterServerPort()
+ val port = entered.trim().toIntOrNull()
+
+ if (port == null || port < 1 || port > 65535) {
+ return "\"" + entered.trim() + "\" is not a valid TCP port. " +
+ "Enter a value from 1 to 65535."
+ }
+
+ prefs.setNotify_dxclusterServerPort(port)
+
+ /*
+ * Restarting on an unchanged port would drop a connected logger for nothing;
+ * the JavaFX field guarded the restart the same way.
+ */
+ if (port != previousPort) {
+ applyDxClusterServerPort(port)
+ }
+
+ return null
+ }
+
+ /**
+ * @return null when stored, otherwise the reason the callsign was refused.
+ */
+ fun commitSpotterCallSign(entered: String): String? {
+ val spotterCallSign = entered.trim().uppercase(Locale.ROOT)
+
+ if (!GuiUtils.isCallSignSyntax(spotterCallSign)) {
+ return "\"" + spotterCallSign + "\" is not a valid spotter callsign."
+ }
+
+ prefs.setNotify_DXCSrv_SpottersCallSign(spotterCallSign)
+ return null
+ }
+
+ /**
+ * Appends a callsign to the monitoring list and fills the roster at once, as the
+ * JavaFX dialog did.
+ *
+ * @return null when added, otherwise the reason it was refused.
+ */
+ fun addMonitoredCallSign(entered: String): String? {
+ val verdict = judgeMonitoredCallSign(entered, monitoredCallSigns.texts)
+
+ if (verdict is CommitOutcome.Refused) {
+ return verdict.message
+ }
+
+ if (verdict !is CommitOutcome.Stored) {
+ return "Please enter a valid callsign."
+ }
+
+ /*
+ * Appended, not inserted at the top: the JavaFX button called add on the
+ * roster. The order is the order the operator entered them in.
+ */
+ monitoredCallSigns.addAtBottom(verdict.text)
+ commitMonitoredCallSigns()
+ return null
+ }
+
+ /** Fills the monitoring roster from the list. */
+ fun commitMonitoredCallSigns() {
+ monitoredCallSigns.commitTo(monitoredRoster)
+ }
+
+ /**
+ * Builds the spot the JavaFX test button sent and hands it to the server.
+ *
+ * @return null when delivered, otherwise the reason it was not.
+ */
+ fun sendTestSpot(): String? {
+ val testSpot = ChatMember()
+ /*
+ * The Java setters are called by name rather than through Kotlin property
+ * assignment: the foreign-write coverage test looks for these three names,
+ * and it is the only check that the test spot still carries its values.
+ */
+ testSpot.setFrequency(SimpleValue("300"))
+ testSpot.setQra("DXC test: You donated \$100!")
+ testSpot.setCallSign("DO5AMF")
+
+ return broadcastTestSpot(testSpot)
+ }
+
+ /**
+ * Monitoring works on the base callsign, so every SSID and portable suffix maps
+ * to one entry. A blank text removes the entry, the removal gesture of every
+ * list in this window.
+ */
+ private fun judgeMonitoredCallSign(candidate: String, others: List): CommitOutcome {
+ if (candidate.isBlank()) {
+ return CommitOutcome.Removed
+ }
+
+ val baseCall = ChatMember.normalizeCallSignToBaseCallSign(
+ candidate.trim().uppercase(Locale.ROOT)
+ )
+
+ if (baseCall == null || !GuiUtils.isCallSignSyntax(baseCall)) {
+ return CommitOutcome.Refused("Please enter a valid callsign.")
+ }
+
+ if (others.any { it != null && it.equals(baseCall, ignoreCase = true) }) {
+ return CommitOutcome.Refused("This base callsign is already in the monitoring list.")
+ }
+
+ return CommitOutcome.Stored(baseCall)
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ProfilesTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ProfilesTab.kt
new file mode 100644
index 00000000..d4b0efc8
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ProfilesTab.kt
@@ -0,0 +1,378 @@
+package kst4contest.view.compose.tabs
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.LocalMinimumInteractiveComponentSize
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.unit.Dp
+import kst4contest.view.compose.Density
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.RadioButton
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import kst4contest.model.OperatorProfile
+import kst4contest.view.compose.Form
+
+/**
+ * The profiles tab.
+ *
+ * Every action that cannot be undone asks first, and the question names what will be
+ * removed — the JavaFX pane did that for the deletion and it is the one action here
+ * that destroys data.
+ */
+@Composable
+fun ProfilesTab(state: ProfilesTabState) {
+ var notice by remember { mutableStateOf(null) }
+ var dialog by remember { mutableStateOf(null) }
+
+ fun act(action: () -> ProfileNotice?) {
+ notice = action()
+ }
+
+ Column(
+ modifier = Modifier.padding(4.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Form.labelledRow("Active profile:") { Text(state.activeProfileName) }
+ Form.labelledRow("Settings file:") { Text(state.preferencesPath) }
+ Form.labelledRow("Worked stations:") { Text(state.workedDatabasePath) }
+ }
+
+ ProfileList(state, state::select)
+
+ Row(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
+ Form.button("New profile...") { dialog = ProfileDialog.Create }
+ Form.button("Duplicate...") {
+ dialog = ProfileDialog.Duplicate(state.duplicateNameSuggestion())
+ }
+ Form.button("Rename...") {
+ state.selected?.let { dialog = ProfileDialog.Rename(it.displayName) }
+ }
+ Form.button("Delete...") {
+ val profile = state.selected
+ if (profile == null) {
+ act { state.deleteSelected() }
+ } else {
+ dialog = ProfileDialog.ConfirmDelete(profile, state.describeDeletion(profile))
+ }
+ }
+ }
+
+ Row(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) {
+ Form.button("Change worked stations...") {
+ val profile = state.selected
+ if (profile == null || !state.canChangeWorkedDataMode(profile)) {
+ act { state.changeWorkedDataModeOfSelected(false) }
+ } else {
+ dialog = ProfileDialog.WorkedData(profile, profile.isSharedWorkedDatabase)
+ }
+ }
+
+ Form.button("Switch to selected profile...") { act { state.activateSelected() } }
+ }
+
+ Text(
+ "Each profile has its own settings and window layout. A profile can either share " +
+ "the common station worked stations, which is what a multi operator station " +
+ "wants, or keep its own. Duplicating a profile copies everything except " +
+ "callsign and password, and never copies worked stations.",
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+
+ dialog?.let { open ->
+ ProfileDialogs(
+ dialog = open,
+ onDismiss = { dialog = null },
+ onCreate = { name, shared -> dialog = null; act { state.createProfile(name, shared) } },
+ onDuplicate = { name -> dialog = null; act { state.duplicateSelected(name) } },
+ onRename = { name -> dialog = null; act { state.renameSelected(name) } },
+ onDelete = { dialog = null; act { state.deleteSelected() } },
+ onWorkedData = { shared -> dialog = null; act { state.changeWorkedDataModeOfSelected(shared) } },
+ )
+ }
+
+ notice?.let { shown ->
+ AlertDialog(
+ onDismissRequest = { notice = null },
+ title = { Text("Operator profiles") },
+ text = { Text(shown.message) },
+ confirmButton = { TextButton(onClick = { notice = null }) { Text("OK") } },
+ )
+ }
+}
+
+/**
+ * The profile table. It observes ProfilesTabState directly: the selection and the list
+ * are Compose state, so a row redraws when the selection moves off it.
+ */
+@Composable
+private fun ProfileList(state: ProfilesTabState, onSelect: (OperatorProfile?) -> Unit) {
+ if (state.profiles.isEmpty()) {
+ Text("No operator profile configured.")
+ return
+ }
+
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
+ HeaderCell("Profile", 0.4f)
+ HeaderCell("Worked stations", 0.4f)
+ HeaderCell("Last used", 0.2f)
+ }
+
+ LazyColumn(modifier = Modifier.height(200.dp).fillMaxWidth()) {
+ items(state.profiles, key = { it.profileId }) { profile ->
+ val isSelected = profile == state.selected
+
+ Column {
+ /*
+ * The selected row carries a full frame in the accent colour on top
+ * of the tint. Unlike the editable lists there is no field border
+ * here to compete with, so the frame is the clearest marker.
+ */
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(
+ if (isSelected) {
+ MaterialTheme.colorScheme.secondaryContainer
+ } else {
+ MaterialTheme.colorScheme.surface
+ }
+ )
+ .then(
+ if (isSelected) {
+ Modifier.border(
+ Density.SELECTION_BORDER,
+ MaterialTheme.colorScheme.primary,
+ )
+ } else {
+ Modifier
+ }
+ )
+ .clickable { onSelect(profile) }
+ .heightIn(min = Density.ROW_MIN_HEIGHT)
+ .padding(horizontal = 4.dp, vertical = 3.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ profile.displayName + if (state.isActive(profile)) " (in use)" else "",
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.weight(0.4f),
+ )
+ Text(
+ ProfilesTabState.describeWorkedDataMode(profile),
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.weight(0.4f),
+ )
+ Text(
+ ProfilesTabState.describeLastUsed(profile),
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.weight(0.2f),
+ )
+ }
+
+ /* A hairline between rows, so the list reads as a table. */
+ if (!isSelected) {
+ HorizontalDivider(thickness = Density.HAIRLINE)
+ }
+ }
+ }
+ }
+ }
+}
+
+/** Extension on RowScope because only that scope provides the weight modifier. */
+@Composable
+private fun RowScope.HeaderCell(text: String, weight: Float) {
+ Text(text, fontWeight = FontWeight.Bold, modifier = Modifier.weight(weight))
+}
+
+/** The dialogs the profile actions need, so each action asks before it acts. */
+private sealed interface ProfileDialog {
+ data object Create : ProfileDialog
+ data class Duplicate(val suggestion: String) : ProfileDialog
+ data class Rename(val current: String) : ProfileDialog
+ data class ConfirmDelete(val profile: OperatorProfile, val consequences: String) : ProfileDialog
+ data class WorkedData(val profile: OperatorProfile, val shared: Boolean) : ProfileDialog
+}
+
+@Composable
+private fun ProfileDialogs(
+ dialog: ProfileDialog,
+ onDismiss: () -> Unit,
+ onCreate: (String, Boolean) -> Unit,
+ onDuplicate: (String) -> Unit,
+ onRename: (String) -> Unit,
+ onDelete: () -> Unit,
+ onWorkedData: (Boolean) -> Unit,
+) {
+ when (dialog) {
+ ProfileDialog.Create -> {
+ var name by remember { mutableStateOf("") }
+ var shared by remember { mutableStateOf(false) }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text("New operator profile") },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("Name and worked stations of the new profile")
+ OutlinedTextField(
+ value = name,
+ onValueChange = { name = it },
+ singleLine = true,
+ label = { Text("Profile name, for example DN9APW") },
+ )
+ WorkedDataChoice(shared) { shared = it }
+ }
+ },
+ confirmButton = { TextButton(onClick = { onCreate(name, shared) }) { Text("Create profile") } },
+ dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
+ )
+ }
+
+ is ProfileDialog.Duplicate -> {
+ var name by remember { mutableStateOf(dialog.suggestion) }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text("Duplicate operator profile") },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("Name of the new profile")
+ Text(
+ "Everything is copied except callsign and password. Worked stations " +
+ "are never copied.",
+ style = MaterialTheme.typography.bodySmall,
+ )
+ OutlinedTextField(
+ value = name,
+ onValueChange = { name = it },
+ singleLine = true,
+ )
+ }
+ },
+ confirmButton = { TextButton(onClick = { onDuplicate(name) }) { Text("OK") } },
+ dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
+ )
+ }
+
+ is ProfileDialog.Rename -> {
+ var name by remember { mutableStateOf(dialog.current) }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text("Rename operator profile") },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("New name of the profile")
+ Text(
+ "Files and folders of the profile are not touched.",
+ style = MaterialTheme.typography.bodySmall,
+ )
+ OutlinedTextField(
+ value = name,
+ onValueChange = { name = it },
+ singleLine = true,
+ )
+ }
+ },
+ confirmButton = { TextButton(onClick = { onRename(name) }) { Text("OK") } },
+ dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
+ )
+ }
+
+ is ProfileDialog.ConfirmDelete -> AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text("Delete operator profile") },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("Delete the profile \"" + dialog.profile.displayName + "\"?")
+ Text(dialog.consequences)
+ }
+ },
+ confirmButton = { TextButton(onClick = onDelete) { Text("Delete profile") } },
+ dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
+ )
+
+ is ProfileDialog.WorkedData -> {
+ var shared by remember { mutableStateOf(dialog.shared) }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text("Worked stations") },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("Worked stations of \"" + dialog.profile.displayName + "\"")
+ WorkedDataChoice(shared) { shared = it }
+ Text(
+ "Switching does not move any data. Worked stations already collected " +
+ "under the other setting stay where they are.",
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ },
+ confirmButton = { TextButton(onClick = { onWorkedData(shared) }) { Text("Apply") } },
+ dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
+ )
+ }
+ }
+}
+
+/** The two mutually exclusive worked-stations options, as the radio buttons were. */
+@Composable
+private fun WorkedDataChoice(shared: Boolean, onChange: (Boolean) -> Unit) {
+ Column {
+ CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ RadioButton(
+ selected = !shared,
+ onClick = { onChange(false) },
+ modifier = Modifier.size(Density.CHECK_SIZE),
+ )
+ Text(
+ "Own worked stations for this profile",
+ modifier = Modifier.padding(start = 6.dp),
+ )
+ }
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ RadioButton(
+ selected = shared,
+ onClick = { onChange(true) },
+ modifier = Modifier.size(Density.CHECK_SIZE),
+ )
+ Text(
+ "Share the common station worked stations (multi operator station)",
+ modifier = Modifier.padding(start = 6.dp),
+ )
+ }
+ }
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ProfilesTabState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ProfilesTabState.kt
new file mode 100644
index 00000000..6b68052d
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ProfilesTabState.kt
@@ -0,0 +1,246 @@
+package kst4contest.view.compose.tabs
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import kst4contest.controller.OperatorProfileManagementService
+import kst4contest.model.OperatorProfile
+import kst4contest.model.OperatorProfileSelection
+import java.time.Instant
+import java.time.ZoneId
+import java.time.format.DateTimeFormatter
+
+/** What the profile tab has to tell the operator after an action. */
+sealed interface ProfileNotice {
+
+ val message: String
+
+ /** Succeeded, but there is something the operator needs to know. */
+ data class Info(override val message: String) : ProfileNotice
+
+ /** Did not happen, and why. */
+ data class Error(override val message: String) : ProfileNotice
+}
+
+/**
+ * State of the profiles tab.
+ *
+ * Every rule of `OperatorProfileSettingsPane` lives here so it can be tested without
+ * a toolkit: the default profile cannot be deleted and always uses the common worked
+ * stations because that database belongs to the installation, the profile in use
+ * cannot be deleted, and switching to the profile already in use does nothing.
+ *
+ * @param activeSelection the profile in use; a function rather than a value because
+ * it changes under the tab when the operator switches profiles
+ * @param requestActivation hands the switch to the application, which restarts into
+ * the other profile — the tab must not do that itself
+ */
+class ProfilesTabState(
+ private val service: OperatorProfileManagementService,
+ private val activeSelection: () -> OperatorProfileSelection?,
+ private val requestActivation: (OperatorProfile) -> Unit,
+) {
+
+ /*
+ * Compose state, not plain properties. A LazyColumn row only redraws when
+ * something it observes changes; with plain properties the selection moved in the
+ * state and the list went on showing the old row highlighted.
+ */
+ var profiles: List by mutableStateOf(emptyList())
+ private set
+
+ var selected: OperatorProfile? by mutableStateOf(null)
+ private set
+
+ init {
+ refresh()
+ }
+
+ val activeProfileName: String
+ get() = activeSelection()?.profile?.displayName ?: "unknown"
+
+ val preferencesPath: String
+ get() = activeSelection()?.preferencesAbsolutePath ?: ""
+
+ val workedDatabasePath: String
+ get() = activeSelection()?.workedDatabaseAbsolutePath ?: ""
+
+ fun select(profile: OperatorProfile?) {
+ selected = profile?.takeIf { profiles.contains(it) }
+ }
+
+ /**
+ * Re-reads the registry and keeps the selection when the selected profile is
+ * still there, as `refreshProfileTable` did.
+ */
+ fun refresh() {
+ val known = service.listProfiles()
+ profiles = known
+
+ selected = when {
+ selected != null && known.contains(selected) -> selected
+ known.isNotEmpty() -> known.first()
+ else -> null
+ }
+ }
+
+ fun createProfile(name: String, sharedWorkedDatabase: Boolean): ProfileNotice? {
+ if (name.isBlank()) {
+ return null
+ }
+
+ val created = service.createProfile(name.trim(), sharedWorkedDatabase)
+ ?: return ProfileNotice.Error(
+ "The profile could not be created. The profile registry could not be written."
+ )
+
+ refresh()
+ selected = created
+
+ /*
+ * Said explicitly because a new profile that cannot log in looks broken
+ * otherwise: callsign and password are deliberately not copied or invented.
+ */
+ return ProfileNotice.Info(
+ "The profile \"" + created.displayName + "\" was created without callsign and " +
+ "password. Enter them on the Station tab after switching to it."
+ )
+ }
+
+ fun duplicateSelected(name: String): ProfileNotice? {
+ val profile = selected ?: return notASelection()
+
+ if (name.isBlank()) {
+ return null
+ }
+
+ val duplicate = service.duplicateProfile(profile, name)
+ ?: return ProfileNotice.Error("The profile could not be duplicated.")
+
+ refresh()
+ selected = duplicate
+ return null
+ }
+
+ /** The suggested name of a duplicate, the one the JavaFX dialog pre-filled. */
+ fun duplicateNameSuggestion(): String =
+ selected?.let { "Copy of " + it.displayName } ?: ""
+
+ fun renameSelected(name: String): ProfileNotice? {
+ val profile = selected ?: return notASelection()
+
+ if (name.isBlank()) {
+ return null
+ }
+
+ service.renameProfile(profile, name)
+ refresh()
+ return null
+ }
+
+ fun deleteSelected(): ProfileNotice? {
+ val profile = selected ?: return notASelection()
+
+ if (profile.isRootProfile) {
+ return ProfileNotice.Error(
+ "The default profile uses the files of the installation itself and cannot be deleted."
+ )
+ }
+
+ if (isActive(profile)) {
+ return ProfileNotice.Error(
+ "The profile currently in use cannot be deleted. Switch to another profile first."
+ )
+ }
+
+ if (!service.deleteProfile(profile)) {
+ refresh()
+ return ProfileNotice.Error("The profile could not be deleted.")
+ }
+
+ refresh()
+ return null
+ }
+
+ /** The folder the deletion confirmation names, and what happens to worked stations. */
+ fun describeDeletion(profile: OperatorProfile): String =
+ "The following folder is removed permanently:\n" +
+ service.getProfileDirectory(profile) + "\n\n" +
+ if (profile.isSharedWorkedDatabase) {
+ "The common station worked stations are not touched."
+ } else {
+ "The worked stations of this profile are deleted as well."
+ }
+
+ fun changeWorkedDataModeOfSelected(sharedWorkedDatabase: Boolean): ProfileNotice? {
+ val profile = selected ?: return notASelection()
+
+ if (profile.isRootProfile) {
+ return ProfileNotice.Error(
+ "The default profile always uses the common station worked stations, because " +
+ "that database is the one of the installation itself."
+ )
+ }
+
+ if (sharedWorkedDatabase == profile.isSharedWorkedDatabase) {
+ return null
+ }
+
+ service.setSharedWorkedDatabase(profile, sharedWorkedDatabase)
+ refresh()
+
+ if (isActive(profile)) {
+ return ProfileNotice.Info("The change takes effect after switching to this profile again.")
+ }
+
+ return null
+ }
+
+ fun activateSelected(): ProfileNotice? {
+ val profile = selected ?: return notASelection()
+
+ if (isActive(profile)) {
+ return ProfileNotice.Info("This profile is already active.")
+ }
+
+ requestActivation(profile)
+ return null
+ }
+
+ /** Whether changing the worked-stations mode is possible at all for a profile. */
+ fun canChangeWorkedDataMode(profile: OperatorProfile): Boolean = !profile.isRootProfile
+
+ fun isActive(profile: OperatorProfile): Boolean {
+ val active = activeSelection() ?: return false
+
+ return active.profile.profileId == profile.profileId
+ }
+
+ private fun notASelection(): ProfileNotice =
+ ProfileNotice.Info("Select a profile in the table first.")
+
+ companion object {
+
+ private val LAST_USED_FORMATTER: DateTimeFormatter =
+ DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm").withZone(ZoneId.systemDefault())
+
+ /**
+ * The root profile always reads the installation's own database, whatever its
+ * stored flag says, which is why it is checked first here too.
+ */
+ fun describeWorkedDataMode(profile: OperatorProfile): String =
+ if (profile.isRootProfile || profile.isSharedWorkedDatabase) {
+ "common station database"
+ } else {
+ "own database"
+ }
+
+ /** Blank for a profile never used, rather than an epoch date. */
+ fun describeLastUsed(profile: OperatorProfile): String =
+ if (profile.lastUsedEpochMs <= 0L) {
+ ""
+ } else {
+ LAST_USED_FORMATTER.format(Instant.ofEpochMilli(profile.lastUsedEpochMs))
+ }
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ShortcutsTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ShortcutsTab.kt
new file mode 100644
index 00000000..d7a63e15
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ShortcutsTab.kt
@@ -0,0 +1,45 @@
+package kst4contest.view.compose.tabs
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import kst4contest.view.compose.DataTable
+import kst4contest.view.compose.Form
+
+/**
+ * The shortcuts-and-snippets tab: the two editable lists.
+ *
+ * The move buttons are shown here because the JavaFX tab had them for both lists —
+ * the order decides the button order above the message field and which snippets get
+ * Ctrl+1 through Ctrl+0.
+ */
+@Composable
+fun ShortcutsTab(state: ShortcutsTabState, onRefused: (String) -> Unit) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Form.section("Shortcut buttons above the message field") {
+ DataTable(
+ state = state.shortcuts,
+ addButtonText = "Add shortcut",
+ newEntryTemplate = ShortcutsTabState.NEW_ENTRY_TEMPLATE,
+ onChanged = state::commitShortcuts,
+ onRefused = onRefused,
+ showMoveButtons = true,
+ )
+ }
+
+ Form.section("Text snippets (the first 10 use Ctrl+1 through Ctrl+0)") {
+ DataTable(
+ state = state.snippets,
+ addButtonText = "Add new snippet",
+ newEntryTemplate = ShortcutsTabState.NEW_ENTRY_TEMPLATE,
+ onChanged = state::commitSnippets,
+ onRefused = onRefused,
+ showMoveButtons = true,
+ )
+ }
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ShortcutsTabState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ShortcutsTabState.kt
new file mode 100644
index 00000000..cc599984
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/ShortcutsTabState.kt
@@ -0,0 +1,51 @@
+package kst4contest.view.compose.tabs
+
+import kst4contest.model.ChatPreferences
+import kst4contest.view.compose.EditableListState
+
+/**
+ * State of the shortcuts-and-snippets tab: the two single-column lists from
+ * `grdPnlShorts`.
+ *
+ * Both lists write through to their roster on every committed change. The JavaFX
+ * tables did the same — their Add buttons called `mutate` on the roster directly —
+ * and the shortcut buttons above the message field are rebuilt from the roster, so
+ * a list that only wrote on Save would leave those buttons stale.
+ *
+ * @param refreshShortcutButtons rebuilds the button row above the message field,
+ * as `refreshShortcutButtons` did after every edit commit and move.
+ * @param refreshTextSnippetContextMenus rebuilds the Ctrl+1..Ctrl+0 menus, the
+ * counterpart for the snippet list.
+ */
+class ShortcutsTabState(
+ private val prefs: ChatPreferences,
+ private val refreshShortcutButtons: () -> Unit,
+ private val refreshTextSnippetContextMenus: () -> Unit,
+) {
+
+ val shortcuts = EditableListState(prefs.getLst_txtShortCutBtnList().snapshot())
+
+ val snippets = EditableListState(prefs.getLst_txtSnipList().snapshot())
+
+ /** Fills the shortcut roster and rebuilds the buttons built from it. */
+ fun commitShortcuts() {
+ shortcuts.commitTo(prefs.getLst_txtShortCutBtnList())
+ refreshShortcutButtons()
+ }
+
+ /** Fills the snippet roster and rebuilds the context menus built from it. */
+ fun commitSnippets() {
+ snippets.commitTo(prefs.getLst_txtSnipList())
+ refreshTextSnippetContextMenus()
+ }
+
+ companion object {
+ /**
+ * The text the JavaFX Add buttons inserted, kept verbatim: it tells the
+ * operator both how to edit the entry and how to remove it again
+ * (Kst4ContestApplication:11743 and :11797, identical in both lists).
+ */
+ const val NEW_ENTRY_TEMPLATE =
+ "CHANGE THIS TEXT VIA DOUBLECLICK or remove by deleting all text. Then hit enter key"
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTab.kt
new file mode 100644
index 00000000..1e730771
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTab.kt
@@ -0,0 +1,103 @@
+package kst4contest.view.compose.tabs
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import kst4contest.view.compose.Form
+
+/**
+ * The Station tab: own station, server, active bands and path-analysis defaults.
+ *
+ * No confirmation callback: StationTabState writes through to ChatPreferences, the
+ * way the JavaFX controls did. "Save settings" persists, it does not collect.
+ */
+@Composable
+fun StationTab(state: StationTabState) {
+ Column(
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ /*
+ * These are the login credentials, so they are locked while the session is
+ * logged in: the running connection would not pick up a change.
+ */
+ Form.section("Own station") {
+ Form.text("Callsign", state.stn_loginCallSign, state.loginFieldsEnabled) {
+ state.stn_loginCallSign = it
+ }
+ Form.text("Name, main category", state.stn_loginNameMainCat, state.loginFieldsEnabled) {
+ state.stn_loginNameMainCat = it
+ }
+ Form.text("Name, second category", state.stn_loginNameSecondCat, state.loginFieldsEnabled) {
+ state.stn_loginNameSecondCat = it
+ }
+ Form.text("Locator", state.stn_loginLocatorMainCat, state.loginFieldsEnabled) {
+ state.stn_loginLocatorMainCat = it
+ }
+ Form.password("Password", state.stn_loginPassword, state.loginFieldsEnabled) {
+ state.stn_loginPassword = it
+ }
+ }
+
+ Form.section("Chat category") {
+ Form.choice(
+ label = "Main category",
+ items = state.availableCategories,
+ selected = state.loginChatCategoryMain,
+ describe = state::describeCategory,
+ enabled = state.loginFieldsEnabled,
+ onSelect = state::selectMainCategory,
+ )
+ Form.check("2nd chat: log in to a second category", state.loginToSecondChatEnabled) {
+ state.loginToSecondChatEnabled = it
+ }
+ Form.choice(
+ label = "Second category",
+ items = state.secondCategoryOptions,
+ selected = state.loginChatCategorySecond,
+ describe = state::describeCategory,
+ enabled = state.loginFieldsEnabled,
+ onSelect = state::selectSecondCategory,
+ )
+ }
+
+ Form.section("ON4KST server") {
+ Form.text("Server host name", state.stn_on4kstServersDns) { state.stn_on4kstServersDns = it }
+ Form.int("Server port", state.stn_on4kstServersPort) { state.stn_on4kstServersPort = it }
+ }
+
+ Form.flowingSection("Active bands") {
+ Form.check("50 MHz", state.stn_bandActive50) { state.stn_bandActive50 = it }
+ Form.check("70 MHz", state.stn_bandActive70) { state.stn_bandActive70 = it }
+ Form.check("144 MHz", state.stn_bandActive144) { state.stn_bandActive144 = it }
+ Form.check("432 MHz", state.stn_bandActive432) { state.stn_bandActive432 = it }
+ Form.check("1240 MHz", state.stn_bandActive1240) { state.stn_bandActive1240 = it }
+ Form.check("2300 MHz", state.stn_bandActive2300) { state.stn_bandActive2300 = it }
+ Form.check("3400 MHz", state.stn_bandActive3400) { state.stn_bandActive3400 = it }
+ Form.check("5600 MHz", state.stn_bandActive5600) { state.stn_bandActive5600 = it }
+ Form.check("10 GHz", state.stn_bandActive10G) { state.stn_bandActive10G = it }
+ }
+
+ Form.section("Defaults") {
+ Form.decimal("Maximum QRB", state.stn_maxQRBDefault) { state.stn_maxQRBDefault = it }
+ Form.decimal("QTF", state.stn_qtfDefault) { state.stn_qtfDefault = it }
+ Form.decimal("Antenna beam width, degrees", state.stn_antennaBeamWidthDeg) { state.stn_antennaBeamWidthDeg = it }
+ }
+
+ Form.section("PSTRotator") {
+ Form.check("Rotator sync enabled", state.stn_pstRotatorEnabled) { state.stn_pstRotatorEnabled = it }
+ Form.text("Rotator host", state.stn_pstRotatorHost) { state.stn_pstRotatorHost = it }
+ Form.int("Rotator port", state.stn_pstRotatorPort) { state.stn_pstRotatorPort = it }
+ }
+
+ Form.section("Path analysis defaults") {
+ Form.decimal("Own TX power, watts", state.stn_pathAnalysisOwnTxPowerWatts) { state.stn_pathAnalysisOwnTxPowerWatts = it }
+ Form.decimal("Own antenna gain, dBi", state.stn_pathAnalysisOwnAntennaGainDbi) { state.stn_pathAnalysisOwnAntennaGainDbi = it }
+ Form.decimal("Own antenna height, metres", state.stn_pathAnalysisOwnAntennaHeightMeters) { state.stn_pathAnalysisOwnAntennaHeightMeters = it }
+ Form.decimal("Target TX power, watts", state.stn_pathAnalysisDefaultTargetTxPowerWatts) { state.stn_pathAnalysisDefaultTargetTxPowerWatts = it }
+ Form.decimal("Target antenna gain, dBi", state.stn_pathAnalysisDefaultTargetAntennaGainDbi) { state.stn_pathAnalysisDefaultTargetAntennaGainDbi = it }
+ Form.text("DEM root directory", state.stn_pathAnalysisDemRootDirectory) { state.stn_pathAnalysisDemRootDirectory = it }
+ }
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTabState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTabState.kt
new file mode 100644
index 00000000..eedec553
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTabState.kt
@@ -0,0 +1,213 @@
+package kst4contest.view.compose.tabs
+
+import kst4contest.model.ChatCategory
+import kst4contest.model.ChatPreferences
+
+/**
+ * The Station tab as a typed facade over ChatPreferences.
+ *
+ * Every property writes through immediately, and there is deliberately no apply().
+ * That mirrors the JavaFX window it replaces: its controls each wrote into
+ * ChatPreferences the moment they changed, and its two buttons only persisted
+ * ("Save settings" calls writePreferencesToXmlFile) or closed the window
+ * ("Apply/Close prefs" just hides it). Buffering edits until a confirmation would
+ * change behaviour an operator may rely on: today a changed setting takes effect
+ * in the running session at once, whether or not it is ever written to disk.
+ *
+ * The property list was derived mechanically from the setters the JavaFX tab
+ * called, intersected with the setters ChatPreferences declares.
+ */
+class StationTabState(
+ private val prefs: ChatPreferences,
+ /**
+ * Applies the chat category to the running session. Passed in rather than taken
+ * from a ChatController so the rules stay testable without building a controller,
+ * which would open a database.
+ */
+ private val applyMainCategory: (ChatCategory?) -> Unit,
+ private val applySecondCategory: (ChatCategory?) -> Unit,
+) {
+
+ var loginChatCategoryMain: ChatCategory?
+ get() = prefs.getLoginChatCategoryMain()
+ set(value) { prefs.setLoginChatCategoryMain(value) }
+
+ /** Null means "no second chat" — the JavaFX window wrote null for exactly that. */
+ var loginChatCategorySecond: ChatCategory?
+ get() = prefs.getLoginChatCategorySecond()
+ set(value) { prefs.setLoginChatCategorySecond(value) }
+
+ var stn_antennaBeamWidthDeg: Double
+ get() = prefs.getStn_antennaBeamWidthDeg()
+ set(value) { prefs.setStn_antennaBeamWidthDeg(value) }
+
+ var stn_bandActive10G: Boolean
+ get() = prefs.isStn_bandActive10G()
+ set(value) { prefs.setStn_bandActive10G(value) }
+
+ var stn_bandActive1240: Boolean
+ get() = prefs.isStn_bandActive1240()
+ set(value) { prefs.setStn_bandActive1240(value) }
+
+ var stn_bandActive144: Boolean
+ get() = prefs.isStn_bandActive144()
+ set(value) { prefs.setStn_bandActive144(value) }
+
+ var stn_bandActive2300: Boolean
+ get() = prefs.isStn_bandActive2300()
+ set(value) { prefs.setStn_bandActive2300(value) }
+
+ var stn_bandActive3400: Boolean
+ get() = prefs.isStn_bandActive3400()
+ set(value) { prefs.setStn_bandActive3400(value) }
+
+ var stn_bandActive432: Boolean
+ get() = prefs.isStn_bandActive432()
+ set(value) { prefs.setStn_bandActive432(value) }
+
+ var stn_bandActive50: Boolean
+ get() = prefs.isStn_bandActive50()
+ set(value) { prefs.setStn_bandActive50(value) }
+
+ var stn_bandActive5600: Boolean
+ get() = prefs.isStn_bandActive5600()
+ set(value) { prefs.setStn_bandActive5600(value) }
+
+ var stn_bandActive70: Boolean
+ get() = prefs.isStn_bandActive70()
+ set(value) { prefs.setStn_bandActive70(value) }
+
+ var stn_loginCallSign: String
+ get() = prefs.getStn_loginCallSign()
+ set(value) { prefs.setStn_loginCallSign(value) }
+
+ var stn_loginLocatorMainCat: String
+ get() = prefs.getStn_loginLocatorMainCat()
+ set(value) { prefs.setStn_loginLocatorMainCat(value) }
+
+ var stn_loginNameMainCat: String
+ get() = prefs.getStn_loginNameMainCat()
+ set(value) { prefs.setStn_loginNameMainCat(value) }
+
+ var stn_loginNameSecondCat: String
+ get() = prefs.getStn_loginNameSecondCat()
+ set(value) { prefs.setStn_loginNameSecondCat(value) }
+
+ var stn_loginPassword: String
+ get() = prefs.getStn_loginPassword()
+ set(value) { prefs.setStn_loginPassword(value) }
+
+ var stn_maxQRBDefault: Double
+ get() = prefs.getStn_maxQRBDefault()
+ set(value) { prefs.setStn_maxQRBDefault(value) }
+
+ var stn_on4kstServersDns: String
+ get() = prefs.getStn_on4kstServersDns()
+ set(value) { prefs.setStn_on4kstServersDns(value) }
+
+ var stn_on4kstServersPort: Int
+ get() = prefs.getStn_on4kstServersPort()
+ set(value) { prefs.setStn_on4kstServersPort(value) }
+
+ var stn_pathAnalysisDefaultTargetAntennaGainDbi: Double
+ get() = prefs.getStn_pathAnalysisDefaultTargetAntennaGainDbi()
+ set(value) { prefs.setStn_pathAnalysisDefaultTargetAntennaGainDbi(value) }
+
+ var stn_pathAnalysisDefaultTargetTxPowerWatts: Double
+ get() = prefs.getStn_pathAnalysisDefaultTargetTxPowerWatts()
+ set(value) { prefs.setStn_pathAnalysisDefaultTargetTxPowerWatts(value) }
+
+ var stn_pathAnalysisDemRootDirectory: String
+ get() = prefs.getStn_pathAnalysisDemRootDirectory()
+ set(value) { prefs.setStn_pathAnalysisDemRootDirectory(value) }
+
+ var stn_pathAnalysisOwnAntennaGainDbi: Double
+ get() = prefs.getStn_pathAnalysisOwnAntennaGainDbi()
+ set(value) { prefs.setStn_pathAnalysisOwnAntennaGainDbi(value) }
+
+ var stn_pathAnalysisOwnAntennaHeightMeters: Double
+ get() = prefs.getStn_pathAnalysisOwnAntennaHeightMeters()
+ set(value) { prefs.setStn_pathAnalysisOwnAntennaHeightMeters(value) }
+
+ var stn_pathAnalysisOwnTxPowerWatts: Double
+ get() = prefs.getStn_pathAnalysisOwnTxPowerWatts()
+ set(value) { prefs.setStn_pathAnalysisOwnTxPowerWatts(value) }
+
+ var stn_pstRotatorEnabled: Boolean
+ get() = prefs.isStn_pstRotatorEnabled()
+ set(value) { prefs.setStn_pstRotatorEnabled(value) }
+
+ var stn_pstRotatorHost: String
+ get() = prefs.getStn_pstRotatorHost()
+ set(value) { prefs.setStn_pstRotatorHost(value) }
+
+ var stn_pstRotatorPort: Int
+ get() = prefs.getStn_pstRotatorPort()
+ set(value) { prefs.setStn_pstRotatorPort(value) }
+
+ var stn_qtfDefault: Double
+ get() = prefs.getStn_qtfDefault()
+ set(value) { prefs.setStn_qtfDefault(value) }
+
+ /**
+ * The chat category is held twice: in ChatPreferences and in ChatController.
+ * The JavaFX window wrote both on every selection change, and the second write
+ * is invisible to the settings coverage test because ChatController is not
+ * ChatPreferences. Dropping it would leave the running session on the old
+ * category while the stored setting said otherwise.
+ */
+ /**
+ * Whether the station logs into a second chat category as well.
+ *
+ * It lives on the Station tab because that is where the JavaFX checkbox stood
+ * (Kst4ContestApplication:9779, "2nd Chat:"), right next to the two category
+ * pickers it governs. It decides what the Connect button does, so it belongs with
+ * the login settings and not with the display options.
+ */
+ var loginToSecondChatEnabled: Boolean
+ get() = prefs.isLoginToSecondChatEnabled()
+ set(value) { prefs.setLoginToSecondChatEnabled(value) }
+
+ /**
+ * Whether the login fields may be edited. The JavaFX window disabled callsign,
+ * password, name, locator and both category pickers once the session was logged
+ * in: changing them there would not reach the running connection, so an edit
+ * would look applied and be ignored until the next login.
+ */
+ var loginFieldsEnabled: Boolean = true
+
+ /**
+ * The categories the operator can log into, built the way the JavaFX choice box
+ * was: one per entry of `getPossibleCategoryNumbers`, numbered from 1.
+ */
+ val availableCategories: List by lazy {
+ val probe = ChatCategory(0)
+
+ (1..probe.possibleCategoryNumbers.size).map { ChatCategory(it) }
+ }
+
+ /**
+ * The categories offered for the second chat. The main category is left out — the
+ * JavaFX box did that too, and logging into the same chat twice is not a thing.
+ */
+ val secondCategoryOptions: List
+ get() {
+ val mainNumber = loginChatCategoryMain?.categoryNumber
+
+ return availableCategories.filter { it.categoryNumber != mainNumber }
+ }
+
+ /** The label a category carries in the pickers. */
+ fun describeCategory(category: ChatCategory): String =
+ category.getChatCategoryName(category.categoryNumber)
+
+ fun selectMainCategory(category: ChatCategory?) {
+ loginChatCategoryMain = category
+ applyMainCategory(category)
+ }
+
+ fun selectSecondCategory(category: ChatCategory?) {
+ loginChatCategorySecond = category
+ applySecondCategory(category)
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/TrxSynchTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/TrxSynchTab.kt
new file mode 100644
index 00000000..51c10cb1
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/TrxSynchTab.kt
@@ -0,0 +1,67 @@
+package kst4contest.view.compose.tabs
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import kst4contest.view.compose.Form
+
+/**
+ * The TRX synch tab: which source may update the own QRG.
+ *
+ * The hint below the sources is not filler. PROJECT_CONTEXT records that an automatic
+ * QRG update needs an enabled source *and* valid incoming RadioInfo or Win-Test STATUS
+ * data, so a checked box must not read as "my QRG is being kept current".
+ *
+ * No confirmation callback: TrxSynchTabState writes through to ChatPreferences, the way
+ * the JavaFX controls did.
+ */
+@Composable
+fun TrxSynchTab(state: TrxSynchTabState) {
+ /* See LogSynchTab: the facade holds no snapshot state, so writes need a revision. */
+ var revision by remember { mutableStateOf(0) }
+
+ Column(
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ @Suppress("UNUSED_EXPRESSION") revision
+
+ Form.section("Receive UCXLog TRX info") {
+ Form.check(
+ "Update MYQRG from RadioInfo messages received on the shared log-sync port",
+ state.trxSynch_ucxLogUDPListenerEnabled,
+ ) { state.trxSynch_ucxLogUDPListenerEnabled = it; revision++ }
+ }
+
+ Form.section("Win-Test TRX sync") {
+ Form.check(
+ "Win-Test STATUS QRG Sync (updates own QRG from Win-Test transceiver frequency)",
+ state.logsynch_wintestQrgSyncEnabled,
+ ) { state.logsynch_wintestQrgSyncEnabled = it; revision++ }
+
+ Form.check(
+ "Use pass frequency from Win-Test STATUS (instead of own QRG)",
+ state.logsynch_wintestUsePassQrg,
+ ) { state.logsynch_wintestUsePassQrg = it; revision++ }
+
+ GuardedTextField(
+ label = "Win-Test station name filter (e.g. STN1, empty = accept all)",
+ value = state.logsynch_wintestNetworkStationNameOfWintestClient1,
+ onAccepted = {
+ state.logsynch_wintestNetworkStationNameOfWintestClient1 = it
+ revision++
+ },
+ )
+ }
+
+ Form.section("What an enabled source does") {
+ Text(TrxSynchTabState.QRG_SOURCE_HINT)
+ }
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/TrxSynchTabState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/TrxSynchTabState.kt
new file mode 100644
index 00000000..2edd3f0c
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/TrxSynchTabState.kt
@@ -0,0 +1,76 @@
+package kst4contest.view.compose.tabs
+
+import kst4contest.model.ChatPreferences
+
+/**
+ * The TRX synch tab as a typed facade over ChatPreferences.
+ *
+ * Every property writes through immediately and there is deliberately no apply(), the
+ * way the JavaFX controls behaved.
+ *
+ * The tab carries the two sources that may update the own QRG. Both live under the
+ * logsynch_ and trxSynch_ prefixes, but the JavaFX window put both controls on this
+ * pane — the Win-Test QRG controls were built next to the Log synch ones and then
+ * added to the TRX synch grid.
+ *
+ * Switching a source on is not itself a frequency: PROJECT_CONTEXT records that an
+ * automatic QRG update needs an enabled source *and* valid incoming RadioInfo or
+ * Win-Test STATUS data. [QRG_SOURCE_HINT] is the text the tab shows so an operator
+ * cannot read a checked box as "my QRG is being kept current".
+ *
+ * @param applyOwnQrgFollower attaches the own-QRG follower when any source is enabled
+ * and detaches it otherwise; maps to attachOwnQrgFollower()/detachOwnQrgFollower()
+ * plus the tooltip on the own-QRG field. Not a ChatPreferences setter, so no coverage
+ * test can see it, and passed in so the rules stay testable without a controller.
+ */
+class TrxSynchTabState(
+ private val prefs: ChatPreferences,
+ private val applyOwnQrgFollower: (Boolean) -> Unit,
+) {
+
+ var trxSynch_ucxLogUDPListenerEnabled: Boolean
+ get() = prefs.isTrxSynch_ucxLogUDPListenerEnabled()
+ set(value) {
+ prefs.setTrxSynch_ucxLogUDPListenerEnabled(value)
+ applyOwnQrgFollower(anyQrgSourceEnabled)
+ }
+
+ var logsynch_wintestQrgSyncEnabled: Boolean
+ get() = prefs.isLogsynch_wintestQrgSyncEnabled()
+ set(value) {
+ prefs.setLogsynch_wintestQrgSyncEnabled(value)
+ applyOwnQrgFollower(anyQrgSourceEnabled)
+ }
+
+ /** Use the Win-Test pass frequency from STATUS instead of the own QRG. */
+ var logsynch_wintestUsePassQrg: Boolean
+ get() = prefs.isLogsynch_wintestUsePassQrg()
+ set(value) { prefs.setLogsynch_wintestUsePassQrg(value) }
+
+ /**
+ * Win-Test station name filter. An empty filter accepts every station, which is
+ * why blank is a legal value here and is stored as the empty string, trimmed the
+ * way the JavaFX field committed it.
+ */
+ var logsynch_wintestNetworkStationNameOfWintestClient1: String
+ get() = prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1()
+ set(value) { prefs.setLogsynch_wintestNetworkStationNameOfWintestClient1(value.trim()) }
+
+ /**
+ * True when at least one source may update the own QRG. One source switched off is
+ * not all of them off; the follower stays attached while the other is on.
+ */
+ val anyQrgSourceEnabled: Boolean
+ get() = trxSynch_ucxLogUDPListenerEnabled || logsynch_wintestQrgSyncEnabled
+
+ companion object {
+ /**
+ * Shown on the tab. Both halves are load-bearing: an enabled source without
+ * incoming data leaves the own QRG exactly as the operator typed it.
+ */
+ const val QRG_SOURCE_HINT: String =
+ "An enabled source does not produce a frequency by itself. The own QRG is " +
+ "only updated once valid RadioInfo or Win-Test STATUS data actually " +
+ "arrives; until then it stays as entered by hand."
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/WorkedDatabaseTab.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/WorkedDatabaseTab.kt
new file mode 100644
index 00000000..b7b1173e
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/WorkedDatabaseTab.kt
@@ -0,0 +1,150 @@
+package kst4contest.view.compose.tabs
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.material3.AlertDialog
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.ui.text.font.FontWeight
+import kst4contest.model.ChatMember
+import androidx.compose.ui.Modifier
+import androidx.compose.material3.Button
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.unit.dp
+import kst4contest.view.compose.Form
+
+/**
+ * The Workedstn database tab: refresh, and a confirmed reset of the contest flags.
+ *
+ * The warning text is the one the JavaFX dialog carried, including the note that a
+ * reset before every contest is normally unnecessary — these data expire on their own.
+ */
+@Composable
+fun WorkedDatabaseTab(state: WorkedDatabaseTabState) {
+ var revision by remember { mutableStateOf(0) }
+
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ @Suppress("UNUSED_EXPRESSION") revision
+
+ Form.section("Worked station database") {
+ Text(
+ "Worked, NOT-QRV and grid data expire after three days on their own. "
+ + "A reset before every contest is normally not required."
+ )
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Form.button("Refresh worked database") { state.refresh(); revision++ }
+ Form.button("Reset worked, NOT-QRV and grid data...") {
+ state.requestReset(); revision++
+ }
+ }
+ state.lastAffectedLines?.let { Text("Last reset affected $it rows.") }
+
+ WorkedStationsTable(state.workedStations, revision)
+ }
+ }
+
+ if (state.confirmationPending) {
+ AlertDialog(
+ onDismissRequest = { state.cancelReset(); revision++ },
+ title = { Text("Reset contest data") },
+ text = {
+ Text(
+ "Reset all worked, NOT-QRV and grid data?\n\n"
+ + "This clears every contest-related flag in the internal database. "
+ + "A reset before every contest is normally not required because "
+ + "these data expire after three days."
+ )
+ },
+ confirmButton = {
+ TextButton(onClick = { state.confirmReset(); revision++ }) { Text("Reset data") }
+ },
+ dismissButton = {
+ TextButton(onClick = { state.cancelReset(); revision++ }) { Text("Cancel") }
+ },
+ )
+ }
+
+ if (state.resetFailed) {
+ AlertDialog(
+ onDismissRequest = { state.dismissError(); revision++ },
+ title = { Text("Reset contest data") },
+ text = {
+ Text(
+ "The contest data could not be reset. The internal database may be "
+ + "unavailable or inconsistent. No successful reset was confirmed."
+ )
+ },
+ confirmButton = {
+ TextButton(onClick = { state.dismissError(); revision++ }) { Text("OK") }
+ },
+ )
+ }
+}
+
+/**
+ * The worked-stations table: one row per callsign in the database, a cross for any
+ * band worked and one column per band.
+ *
+ * Read-only, as the JavaFX table was. The band columns are those the JavaFX table had,
+ * in its order and under its labels — 23, 13, 9, 6 and 3 are centimetre labels for
+ * 1240, 2300, 3400, 5600 and 10 GHz, which is what a microwave operator reads.
+ */
+@Composable
+private fun WorkedStationsTable(rows: List, revision: Int) {
+ @Suppress("UNUSED_EXPRESSION") revision
+
+ val bands: List Boolean>> = listOf(
+ "wkd" to { m: ChatMember -> m.isWorked },
+ "50" to { m: ChatMember -> m.isWorked50 },
+ "70" to { m: ChatMember -> m.isWorked70 },
+ "144" to { m: ChatMember -> m.isWorked144 },
+ "432" to { m: ChatMember -> m.isWorked432 },
+ "23" to { m: ChatMember -> m.isWorked1240 },
+ "13" to { m: ChatMember -> m.isWorked2300 },
+ "9" to { m: ChatMember -> m.isWorked3400 },
+ "6" to { m: ChatMember -> m.isWorked5600 },
+ "3" to { m: ChatMember -> m.isWorked10G },
+ )
+
+ if (rows.isEmpty()) {
+ Text("The worked database holds no callsigns.")
+ return
+ }
+
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Row(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
+ Text("Callsign", fontWeight = FontWeight.Bold, modifier = Modifier.weight(2f))
+ bands.forEach { (label, _) ->
+ Text(label, fontWeight = FontWeight.Bold, modifier = Modifier.weight(1f))
+ }
+ }
+
+ LazyColumn(modifier = Modifier.height(260.dp).fillMaxWidth()) {
+ /*
+ * Keyed by the raw callsign: the database holds one row per callsign, and a
+ * positional key would reshuffle every row when the list is re-read.
+ */
+ items(rows, key = { it.callSignRaw ?: it.callSign ?: "" }) { member ->
+ Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp)) {
+ Text(member.callSign ?: "", modifier = Modifier.weight(2f))
+ bands.forEach { (_, isWorked) ->
+ Text(
+ if (isWorked(member)) "X" else "",
+ modifier = Modifier.weight(1f),
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/WorkedDatabaseTabState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/WorkedDatabaseTabState.kt
new file mode 100644
index 00000000..34d849d9
--- /dev/null
+++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/WorkedDatabaseTabState.kt
@@ -0,0 +1,96 @@
+package kst4contest.view.compose.tabs
+
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import kst4contest.model.ChatMember
+import kst4contest.observe.ObservableRoster
+
+/**
+ * The Workedstn database tab. It holds no settings at all — the JavaFX tab offered
+ * two actions, "Refresh worked database" and "Reset worked, NOT-QRV and grid data".
+ *
+ * Neither coverage net can see this tab: both check setter names, and an action is
+ * not a setter. That is why its behaviour is pinned by the tests around this class
+ * instead.
+ *
+ * The actions are passed in rather than taken from a ChatController so the order
+ * they run in stays testable without opening a database.
+ *
+ * @param resetInDatabase clears the flags and returns the affected row count, or a
+ * negative number when no successful reset could be confirmed
+ * @param refreshGuiLists drops the worked and NOT-QRV marks from the runtime lists
+ * @param refreshView re-reads the worked database for display
+ * @param workedRoster the callsigns read from the worked database; the JavaFX tab
+ * showed them in a table with one column per band, which is the only place the
+ * operator can see what the database actually holds
+ */
+class WorkedDatabaseTabState(
+ private val resetInDatabase: () -> Int,
+ private val refreshGuiLists: () -> Unit,
+ private val refreshView: () -> Unit,
+ private val workedRoster: ObservableRoster? = null,
+) {
+
+ /**
+ * The rows to show. A snapshot, taken when the tab is built and again on every
+ * refresh and reset, exactly as the JavaFX table behaved: it mirrored the roster
+ * and was told to redraw after each of those actions.
+ */
+ var workedStations: List by mutableStateOf(workedRoster?.snapshot() ?: emptyList())
+ private set
+
+ private fun readRoster() {
+ workedStations = workedRoster?.snapshot() ?: emptyList()
+ }
+
+ var confirmationPending: Boolean by mutableStateOf(false)
+ private set
+
+ var resetFailed: Boolean by mutableStateOf(false)
+ private set
+
+ var lastAffectedLines: Int? by mutableStateOf(null)
+ private set
+
+ /** Opens the confirmation. Nothing is touched until [confirmReset]. */
+ fun requestReset() {
+ confirmationPending = true
+ resetFailed = false
+ }
+
+ fun cancelReset() {
+ confirmationPending = false
+ }
+
+ /**
+ * Resets the database first and the runtime lists only afterwards. On failure the
+ * lists are left alone: clearing them would show the operator a state the database
+ * does not have.
+ */
+ fun confirmReset() {
+ confirmationPending = false
+ val affected = resetInDatabase()
+ if (affected < 0) {
+ resetFailed = true
+ return
+ }
+ lastAffectedLines = affected
+ refreshGuiLists()
+
+ /*
+ * The rows are re-read after a successful reset, or the table would keep
+ * showing entries the database no longer has.
+ */
+ readRoster()
+ }
+
+ fun dismissError() {
+ resetFailed = false
+ }
+
+ fun refresh() {
+ refreshView()
+ readRoster()
+ }
+}
diff --git a/app-desktop/src/test/java/kst4contest/view/ComposeMainWindowOptionTest.java b/app-desktop/src/test/java/kst4contest/view/ComposeMainWindowOptionTest.java
new file mode 100644
index 00000000..8b566e5c
--- /dev/null
+++ b/app-desktop/src/test/java/kst4contest/view/ComposeMainWindowOptionTest.java
@@ -0,0 +1,52 @@
+package kst4contest.view;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * The switch that opens the Compose main window beside the JavaFX one.
+ *
+ * Off unless asked for. The two windows are meant to run side by side while the operator
+ * compares them against a live session, and until that comparison is done nothing about an
+ * ordinary start may change.
+ */
+class ComposeMainWindowOptionTest {
+
+ @Test
+ void anOrdinaryStartDoesNotOpenTheComposeWindow() {
+ assertFalse(CommandLineOptions.parse(List.of()).isComposeMainWindowRequested());
+ assertFalse(CommandLineOptions.parse(null).isComposeMainWindowRequested());
+ assertFalse(
+ CommandLineOptions.parse(List.of("--profile", "contest"))
+ .isComposeMainWindowRequested());
+ }
+
+ @Test
+ void theSwitchOpensIt() {
+ assertTrue(
+ CommandLineOptions.parse(List.of("--compose-main-window"))
+ .isComposeMainWindowRequested());
+ }
+
+ /** Alongside a profile, because the comparison is run inside one. */
+ @Test
+ void theSwitchCombinesWithAProfile() {
+ CommandLineOptions options =
+ CommandLineOptions.parse(List.of("--profile", "contest", "--compose-main-window"));
+
+ assertTrue(options.isComposeMainWindowRequested());
+ assertTrue("contest".equals(options.getRequestedProfileName()));
+ }
+
+ /** A near miss must not switch it on by accident. */
+ @Test
+ void anUnrelatedArgumentIsNotTheSwitch() {
+ assertFalse(
+ CommandLineOptions.parse(List.of("--compose-main-window-please"))
+ .isComposeMainWindowRequested());
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/BuildInfoTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/BuildInfoTest.kt
new file mode 100644
index 00000000..85e1a531
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/BuildInfoTest.kt
@@ -0,0 +1,24 @@
+package kst4contest.view
+
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+
+class BuildInfoTest {
+
+ @Test
+ fun `an unstamped build reports a version that cannot be mistaken for a release`() {
+ // Tests run from class directories, where no jar manifest exists.
+ assertEquals(BuildInfo.UNKNOWN_VERSION, BuildInfo.version)
+ assertFalse(BuildInfo.version.first().isDigit(),
+ "an unstamped version must not look like a release number")
+ }
+
+ @Test
+ fun `the window title carries the version`() {
+ val title = BuildInfo.windowTitle("KST4Contest")
+ assertTrue(title.startsWith("KST4Contest "))
+ assertTrue(title.endsWith(BuildInfo.version))
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/AirplaneCellTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/AirplaneCellTest.kt
new file mode 100644
index 00000000..d5a1067c
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/AirplaneCellTest.kt
@@ -0,0 +1,107 @@
+package kst4contest.view.compose
+
+import kst4contest.model.AirPlane
+import kst4contest.model.AirPlaneReflectionInfo
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Test
+
+/**
+ * The aircraft-reflection cell, shared by the station list and the directed messages.
+ *
+ * Both JavaFX tables built this text by hand and identically: the minutes until the
+ * aeroplane arrives and how promising the path is, for at most the first two. The
+ * Compose station list was reading AirPlaneReflectionInfo.toString() instead, which
+ * yields "DL0ABC > OK1XYZ at 3 planes: [...]" — a defect from Etappe 5a that this
+ * test closes.
+ */
+class AirplaneCellTest {
+
+ @Test
+ fun `one aeroplane gives its minutes and its potential`() {
+ val info = reflectionInfo(plane(minutes = 7, potential = 85))
+ assertEquals("7 (85%)", AirplaneCell.format(info))
+ }
+
+ @Test
+ fun `two aeroplanes are separated by a slash`() {
+ val info = reflectionInfo(plane(7, 85), plane(12, 60))
+ assertEquals("7 (85%) / 12 (60%)", AirplaneCell.format(info))
+ }
+
+ /** Only the first two fit in the column; the rest are in the tooltip's business. */
+ @Test
+ fun `a third aeroplane is not shown`() {
+ val info = reflectionInfo(plane(7, 85), plane(12, 60), plane(20, 30))
+ assertEquals("7 (85%) / 12 (60%)", AirplaneCell.format(info))
+ }
+
+ /**
+ * "nil" and not an empty cell: the operator has to tell "no aeroplane is coming"
+ * apart from "nothing has been computed for this station yet".
+ */
+ @Test
+ fun `no aeroplanes reads nil`() {
+ assertEquals("nil", AirplaneCell.format(reflectionInfo()))
+ }
+
+ @Test
+ fun `an unset aeroplane list reads nil too`() {
+ val info = AirPlaneReflectionInfo()
+ info.risingAirplanes = null
+ assertEquals("nil", AirplaneCell.format(info))
+ }
+
+ /** Nothing computed yet leaves the cell blank, as both JavaFX tables did. */
+ @Test
+ fun `no reflection info at all leaves the cell empty`() {
+ assertEquals("", AirplaneCell.format(null))
+ }
+
+ // ---- the colour ---------------------------------------------------------
+
+ /**
+ * The colour is what an aircraft-scatter chance is spotted by; the numbers are read
+ * afterwards. Both stylesheets use the same three: purple for a certain path, red for a
+ * good one, orange for a fair one.
+ */
+ @Test
+ fun `a certain path is purple, a good one red, a fair one orange`() {
+ assertEquals(AirplaneCell.PURPLE_100, AirplaneCell.accentFor("7 (100%)")?.foreground)
+ assertEquals(AirplaneCell.RED_75, AirplaneCell.accentFor("7 (75%)")?.foreground)
+ assertEquals(AirplaneCell.ORANGE_50, AirplaneCell.accentFor("7 (50%)")?.foreground)
+ }
+
+ /**
+ * Pinned as found: JavaFX matched the rendered string, so only exactly 100, 75 and 50
+ * are coloured — 85% gets nothing. Colouring by threshold instead would light up cells
+ * the operator has never seen lit, which changes what the column means at a glance.
+ */
+ @Test
+ fun `a potential between the three steps is not coloured`() {
+ assertNull(AirplaneCell.accentFor("7 (85%)"))
+ assertNull(AirplaneCell.accentFor("7 (60%)"))
+ assertNull(AirplaneCell.accentFor("7 (49%)"))
+ }
+
+ @Test
+ fun `nothing coming is not coloured`() {
+ assertNull(AirplaneCell.accentFor("nil"))
+ assertNull(AirplaneCell.accentFor(""))
+ }
+
+ /** With two aeroplanes the better one decides, as the original's if-chain did. */
+ @Test
+ fun `the best of two potentials decides the colour`() {
+ assertEquals(AirplaneCell.PURPLE_100, AirplaneCell.accentFor("7 (75%) / 12 (100%)")?.foreground)
+ assertEquals(AirplaneCell.RED_75, AirplaneCell.accentFor("7 (50%) / 12 (75%)")?.foreground)
+ }
+
+ private fun reflectionInfo(vararg planes: AirPlane): AirPlaneReflectionInfo =
+ AirPlaneReflectionInfo().apply { risingAirplanes = planes.toMutableList() }
+
+ private fun plane(minutes: Int, potential: Int): AirPlane = AirPlane().apply {
+ arrivingDurationMinutes = minutes
+ this.potential = potential
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/ChatInputStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/ChatInputStateTest.kt
new file mode 100644
index 00000000..35f250fd
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/ChatInputStateTest.kt
@@ -0,0 +1,435 @@
+package kst4contest.view.compose
+
+import androidx.compose.ui.text.TextRange
+import androidx.compose.ui.text.input.TextFieldValue
+import kst4contest.model.ChatCategory
+import kst4contest.model.ChatMember
+import kst4contest.model.ChatMessage
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Assertions.assertSame
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+/**
+ * The line the operator actually types into. Everything here is the JavaFX behaviour;
+ * two places where that behaviour is inconsistent are pinned as they are rather than
+ * quietly repaired, because both decide what gets transmitted.
+ */
+class ChatInputStateTest {
+
+ private val mainCategory = ChatCategory(2)
+ private val memberCategory = ChatCategory(3)
+
+ // ---- sending -----------------------------------------------------------
+
+ @Test
+ fun `sending queues the resolved text and empties the field`() {
+ val actions = FakeActions()
+ val state = ChatInputState(actions)
+ state.value = TextFieldValue("test de dn9apw")
+
+ state.send()
+
+ assertEquals(1, actions.queued.size)
+ assertEquals("test de dn9apw", actions.queued.single().messageText)
+ assertEquals("", state.value.text)
+ }
+
+ @Test
+ fun `the enter key sends the same way the button does`() {
+ val actions = FakeActions()
+ val state = ChatInputState(actions)
+ state.value = TextFieldValue("cq cq")
+
+ state.send()
+
+ assertEquals(1, actions.queued.size)
+ }
+
+ /** A variable may legitimately resolve to nothing; then there is no message. */
+ @Test
+ fun `a text that resolves to nothing is dropped and the field cleared`() {
+ val actions = FakeActions(resolved = " ")
+ val state = ChatInputState(actions)
+ state.value = TextFieldValue("%qrg%")
+
+ state.send()
+
+ assertTrue(actions.queued.isEmpty())
+ assertEquals("", state.value.text)
+ }
+
+ @Test
+ fun `nothing is queued for an empty field`() {
+ val actions = FakeActions(resolved = "")
+ val state = ChatInputState(actions)
+ state.send()
+ assertTrue(actions.queued.isEmpty())
+ }
+
+ /** Sending a private message to yourself is a mistake, not a feature. */
+ @Test
+ fun `a message addressed to the own callsign is refused`() {
+ val actions = FakeActions(addressedToSelf = true)
+ val state = ChatInputState(actions)
+ state.value = TextFieldValue("/cq DN9APW hi")
+
+ state.send()
+
+ assertTrue(actions.queued.isEmpty())
+ assertEquals("", state.value.text)
+ }
+
+ @Test
+ fun `the category comes from the resolver`() {
+ val actions = FakeActions(category = memberCategory)
+ val state = ChatInputState(actions)
+ state.value = TextFieldValue("/cq DL0ABC hi")
+
+ state.send()
+
+ assertSame(memberCategory, actions.queued.single().chatCategory)
+ }
+
+ /** If the category cannot be resolved at all, the main chat is where it goes. */
+ @Test
+ fun `an unresolvable category falls back to the main chat`() {
+ val actions = FakeActions(category = null)
+ val state = ChatInputState(actions)
+ state.value = TextFieldValue("hello")
+
+ state.send()
+
+ assertSame(mainCategory, actions.queued.single().chatCategory)
+ }
+
+ @Test
+ fun `an outgoing message is never addressed to the server`() {
+ val actions = FakeActions()
+ val state = ChatInputState(actions)
+ state.value = TextFieldValue("hello")
+
+ state.send()
+
+ assertEquals(false, actions.queued.single().isMessageDirectedToServer)
+ }
+
+ /** A /cq starts the reply clock, which is what the no-reply tracking runs on. */
+ @Test
+ fun `sending records the outbound cq and asks for a recompute`() {
+ val actions = FakeActions()
+ val state = ChatInputState(actions)
+ state.value = TextFieldValue("/cq DL0ABC pse 23cm")
+
+ state.send()
+
+ assertEquals(listOf("/cq DL0ABC pse 23cm"), actions.recordedCqs)
+ assertEquals(listOf("outbound-tx"), actions.recomputeReasons)
+ }
+
+ // ---- which station the text is resolved against ------------------------
+
+ @Test
+ fun `the panel selection wins over the table`() {
+ val panel = member("DL0ABC")
+ val table = member("OK1XYZ")
+ val actions = FakeActions(panelSelection = panel, tableSelection = table)
+ val state = ChatInputState(actions)
+ state.value = TextFieldValue("%call%")
+
+ state.send()
+
+ assertSame(panel, actions.resolvedAgainst.first())
+ }
+
+ @Test
+ fun `the table selection is used when the panel has none`() {
+ val table = member("OK1XYZ")
+ val actions = FakeActions(tableSelection = table)
+ val state = ChatInputState(actions)
+ state.value = TextFieldValue("%call%")
+
+ state.send()
+
+ assertSame(table, actions.resolvedAgainst.first())
+ }
+
+ /**
+ * Pinned as found, not repaired. On the send path the score service's selection is
+ * consulted only AFTER the variables have been resolved, so a station that only it
+ * knows about does not reach the resolver — while the shortcut buttons, which use
+ * the three-step lookup, do see it. Repairing this would change which station a
+ * variable resolves against, and therefore what gets transmitted.
+ */
+ @Test
+ fun `on the send path the score service selection reaches the category but not the variables`() {
+ val scored = member("S50ZZZ")
+ val actions = FakeActions(scoreSelection = scored)
+ val state = ChatInputState(actions)
+ state.value = TextFieldValue("%call%")
+
+ state.send()
+
+ assertNull(actions.resolvedAgainst.first(), "the resolver saw a station it did not see in JavaFX")
+ assertSame(scored, actions.categoryResolvedAgainst.first())
+ }
+
+ // ---- the shortcut buttons ---------------------------------------------
+
+ /** A shortcut button APPENDS, so several can be strung together into one message. */
+ @Test
+ fun `a shortcut button appends to what is already typed`() {
+ val actions = FakeActions(resolved = "pse sked")
+ val state = ChatInputState(actions)
+ state.value = TextFieldValue("/cq DL0ABC ")
+
+ state.appendShortcut("pse sked")
+
+ assertEquals("/cq DL0ABC pse sked", state.value.text)
+ }
+
+ @Test
+ fun `a shortcut button resolves its variables before appending`() {
+ val actions = FakeActions(resolved = "144.300")
+ val state = ChatInputState(actions)
+
+ state.appendShortcut("MYQRGVAR")
+
+ assertEquals("144.300", state.value.text)
+ assertEquals("MYQRGVAR ", actions.resolvedTemplates.single())
+ }
+
+ @Test
+ fun `a shortcut that resolves to nothing changes nothing`() {
+ val actions = FakeActions(resolved = " ")
+ val state = ChatInputState(actions)
+ state.value = TextFieldValue("keep me")
+
+ state.appendShortcut("%unset%")
+
+ assertEquals("keep me", state.value.text)
+ }
+
+ /** The two QRG buttons append the field contents rather than a resolved template. */
+ @Test
+ fun `the MYQRG button appends the own frequency`() {
+ val actions = FakeActions(ownQrgMain = "144.370")
+ val state = ChatInputState(actions)
+ state.value = TextFieldValue("qrg ")
+
+ state.appendOwnQrg(mainCategory = true)
+
+ assertEquals("qrg 144.370 ", state.value.text)
+ }
+
+ @Test
+ fun `the SECONDQRG button appends the second frequency`() {
+ val actions = FakeActions(ownQrgSecond = "432.200")
+ val state = ChatInputState(actions)
+
+ state.appendOwnQrg(mainCategory = false)
+
+ assertEquals("432.200 ", state.value.text)
+ }
+
+ // ---- the snippets ------------------------------------------------------
+
+ /**
+ * A snippet REPLACES the field, unlike a shortcut button: it builds a whole
+ * directed message, so whatever was half typed would only corrupt it.
+ */
+ @Test
+ fun `a snippet replaces the field with a directed message`() {
+ val actions = FakeActions(panelSelection = member("DL0ABC"), resolved = "/cq DL0ABC pse 23cm")
+ val state = ChatInputState(actions)
+ state.value = TextFieldValue("half typed")
+
+ state.insertSnippet(listOf("pse 23cm", "qrz?"), 0)
+
+ assertEquals("/cq DL0ABC pse 23cm", state.value.text)
+ assertEquals("/cq DL0ABC pse 23cm", actions.resolvedTemplates.single())
+ }
+
+ /** Without a station there is nobody to direct it at, so nothing happens. */
+ @Test
+ fun `a snippet without a selected station does nothing`() {
+ val actions = FakeActions()
+ val state = ChatInputState(actions)
+ state.value = TextFieldValue("keep me")
+
+ state.insertSnippet(listOf("pse 23cm"), 0)
+
+ assertEquals("keep me", state.value.text)
+ assertTrue(actions.resolvedTemplates.isEmpty())
+ }
+
+ @Test
+ fun `a snippet index nobody configured does nothing`() {
+ val actions = FakeActions(panelSelection = member("DL0ABC"))
+ val state = ChatInputState(actions)
+ state.value = TextFieldValue("keep me")
+
+ state.insertSnippet(listOf("only one"), 5)
+ state.insertSnippet(listOf("only one"), -1)
+
+ assertEquals("keep me", state.value.text)
+ }
+
+ @Test
+ fun `the snippet uses the three step station lookup`() {
+ val scored = member("S50ZZZ")
+ val actions = FakeActions(scoreSelection = scored, resolved = "/cq S50ZZZ hi")
+ val state = ChatInputState(actions)
+
+ state.insertSnippet(listOf("hi"), 0)
+
+ assertSame(scored, actions.resolvedAgainst.single())
+ }
+
+ // ---- the context menu -------------------------------------------------
+
+ /**
+ * The same snippet roster reaches the field three ways, and all three differ. Ctrl+N
+ * replaces the field with a directed message; a right-click APPENDS the snippet with
+ * no trailing space and no /cq prefix; a shortcut button appends its own roster's
+ * entry followed by a space. Pinned because collapsing any two of them would change
+ * what goes out.
+ */
+ @Test
+ fun `a context menu entry appends the resolved snippet without a trailing space`() {
+ val actions = FakeActions(panelSelection = member("DL0ABC"), resolved = "pse 23cm")
+ val state = ChatInputState(actions)
+ state.value = TextFieldValue("/cq DL0ABC ")
+
+ state.appendSnippet("pse 23cm")
+
+ assertEquals("/cq DL0ABC pse 23cm", state.value.text)
+ assertEquals("pse 23cm", actions.resolvedTemplates.single(), "a space was added to the template")
+ }
+
+ @Test
+ fun `a context menu entry needs no selected station`() {
+ val actions = FakeActions(resolved = "qrz?")
+ val state = ChatInputState(actions)
+
+ state.appendSnippet("qrz?")
+
+ assertEquals("qrz?", state.value.text)
+ }
+
+ @Test
+ fun `a context menu entry that resolves to nothing changes nothing`() {
+ val actions = FakeActions(resolved = "")
+ val state = ChatInputState(actions)
+ state.value = TextFieldValue("keep me")
+
+ state.appendSnippet("%unset%")
+
+ assertEquals("keep me", state.value.text)
+ }
+
+ // ---- the caret and the focus ------------------------------------------
+
+ /**
+ * After every insertion the caret sits at the end and the field takes the focus
+ * back — the operator is mid sentence, and a caret left at position 0 turns the
+ * next keystroke into a corrupted message.
+ */
+ @Test
+ fun `an insertion puts the caret at the end`() {
+ val actions = FakeActions(resolved = "pse sked")
+ val state = ChatInputState(actions)
+ state.value = TextFieldValue("/cq DL0ABC ", selection = TextRange(0))
+
+ state.appendShortcut("pse sked")
+
+ assertEquals(state.value.text.length, state.value.selection.start)
+ assertTrue(state.value.selection.collapsed)
+ }
+
+ @Test
+ fun `an insertion asks for the focus back`() {
+ val actions = FakeActions(resolved = "x")
+ val state = ChatInputState(actions)
+ val before = state.focusRequests
+
+ state.appendShortcut("x")
+
+ assertTrue(state.focusRequests > before)
+ }
+
+ @Test
+ fun `a refused insertion does not steal the focus`() {
+ val actions = FakeActions(resolved = " ")
+ val state = ChatInputState(actions)
+ val before = state.focusRequests
+
+ state.appendShortcut("%unset%")
+
+ assertEquals(before, state.focusRequests)
+ }
+
+ // ---- clearing ----------------------------------------------------------
+
+ @Test
+ fun `clear empties the field without sending`() {
+ val actions = FakeActions()
+ val state = ChatInputState(actions)
+ state.value = TextFieldValue("unsent")
+
+ state.clear()
+
+ assertEquals("", state.value.text)
+ assertTrue(actions.queued.isEmpty())
+ }
+
+ private fun member(call: String): ChatMember = ChatMember().apply {
+ setCallSign(call)
+ setChatCategory(memberCategory)
+ }
+
+ /** Real behaviour, recorded; no mocking framework needed for a boundary this small. */
+ private inner class FakeActions(
+ private val panelSelection: ChatMember? = null,
+ private val tableSelection: ChatMember? = null,
+ private val scoreSelection: ChatMember? = null,
+ private val resolved: String? = null,
+ private val addressedToSelf: Boolean = false,
+ private val category: ChatCategory? = null,
+ private val ownQrgMain: String = "",
+ private val ownQrgSecond: String = "",
+ ) : ChatInputActions {
+
+ val queued = mutableListOf()
+ val resolvedAgainst = mutableListOf()
+ val categoryResolvedAgainst = mutableListOf()
+ val resolvedTemplates = mutableListOf()
+ val recordedCqs = mutableListOf()
+ val recomputeReasons = mutableListOf()
+
+ override fun panelSelection(): ChatMember? = panelSelection
+ override fun tableSelection(): ChatMember? = tableSelection
+ override fun scoreSelection(): ChatMember? = scoreSelection
+
+ override fun resolveVariables(template: String, member: ChatMember?): String? {
+ resolvedAgainst += member
+ resolvedTemplates += template
+ return resolved ?: template
+ }
+
+ override fun isAddressedToOwnCallsign(text: String): Boolean = addressedToSelf
+
+ override fun resolveCategory(text: String, member: ChatMember?): ChatCategory? {
+ categoryResolvedAgainst += member
+ return category
+ }
+
+ override fun mainCategory(): ChatCategory = this@ChatInputStateTest.mainCategory
+ override fun recordOutboundCq(text: String) { recordedCqs += text }
+ override fun requestRecompute(reason: String) { recomputeReasons += reason }
+ override fun queue(message: ChatMessage) { queued += message }
+ override fun ownQrgMain(): String = ownQrgMain
+ override fun ownQrgSecond(): String = ownQrgSecond
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/DataTableStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/DataTableStateTest.kt
new file mode 100644
index 00000000..3d248afd
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/DataTableStateTest.kt
@@ -0,0 +1,559 @@
+package kst4contest.view.compose
+
+import androidx.compose.runtime.snapshots.Snapshot
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Assertions.assertSame
+import org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * The rules of the multi-column table.
+ *
+ * Row identity, selection across a refresh and stable sorting are the three places
+ * where Etappe 2 lost time, and all three were found by watching the window rather
+ * than by a test. They are pinned here.
+ */
+class DataTableStateTest {
+
+ private class Station(val callSign: String, var qrb: Int)
+
+ private class Spot(val text: String)
+
+ private fun stationTable() = DataTableState(
+ columns = listOf(
+ DataColumn(id = "callsign", title = "Callsign", value = { s: Station -> s.callSign }),
+ DataColumn(
+ id = "qrb",
+ title = "QRB",
+ value = { s: Station -> s.qrb.toString() },
+ comparator = compareBy { s: Station -> s.qrb },
+ ),
+ ),
+ rowKey = RowKeys.byValue { s: Station -> s.callSign },
+ )
+
+ @Test
+ fun `the selection follows the row, not its position`() {
+ val table = stationTable()
+ val dn9apw = Station("DN9APW", 100)
+ table.replaceRows(listOf(dn9apw, Station("DO5AMF", 200)))
+ table.select(dn9apw)
+
+ // Something arrives above it, as happens constantly in a live list.
+ table.replaceRows(listOf(Station("DL1ABC", 50), Station("DN9APW", 100), Station("DO5AMF", 200)))
+
+ assertEquals("DN9APW", table.selectedRow?.callSign,
+ "the station the operator picked is still that station after it moved down")
+ }
+
+ @Test
+ fun `a selected row that disappears clears the selection`() {
+ val table = stationTable()
+ val gone = Station("DN9APW", 100)
+ table.replaceRows(listOf(gone, Station("DO5AMF", 200)))
+ table.select(gone)
+
+ table.replaceRows(listOf(Station("DO5AMF", 200)))
+
+ assertNull(table.selectedRow,
+ "sliding the selection onto the neighbour would point the operator at "
+ + "someone they did not choose")
+ }
+
+ @Test
+ fun `equal rows keep the order they arrived in`() {
+ val table = stationTable()
+ table.replaceRows(
+ listOf(Station("A", 100), Station("B", 100), Station("C", 100)),
+ )
+ table.toggleSort("qrb")
+
+ assertEquals(
+ listOf("A", "B", "C"),
+ table.visibleRows.map { it.callSign },
+ "the sort is stable; Etappe 2 lost this and rows jumped",
+ )
+ }
+
+ @Test
+ fun `a column with a comparator sorts by it, not by its text`() {
+ val table = stationTable()
+ table.replaceRows(listOf(Station("A", 9), Station("B", 100)))
+
+ table.toggleSort("qrb")
+
+ assertEquals(listOf("A", "B"), table.visibleRows.map { it.callSign },
+ "9 before 100 numerically; by text it would be the other way round")
+ }
+
+ @Test
+ fun `a column without a comparator sorts by the shown text`() {
+ val table = stationTable()
+ table.replaceRows(listOf(Station("DO5AMF", 1), Station("DL1ABC", 2)))
+
+ table.toggleSort("callsign")
+
+ assertEquals(listOf("DL1ABC", "DO5AMF"), table.visibleRows.map { it.callSign })
+ }
+
+ @Test
+ fun `clicking the sorted column again reverses it`() {
+ val table = stationTable()
+ table.replaceRows(listOf(Station("A", 1), Station("B", 2)))
+
+ table.toggleSort("qrb")
+ assertTrue(table.sortAscending)
+
+ table.toggleSort("qrb")
+ assertFalse(table.sortAscending)
+ assertEquals(listOf("B", "A"), table.visibleRows.map { it.callSign })
+ }
+
+ /**
+ * The third click clears the sorting and puts the rows back in arrival order. JavaFX
+ * TableView cycles ascending, descending, unsorted for free; losing that meant an
+ * operator who had sorted the message list once could never get back to "newest at
+ * the bottom" without restarting.
+ */
+ @Test
+ fun `a third click clears the sorting and restores arrival order`() {
+ val table = stationTable()
+ table.replaceRows(listOf(Station("B", 2), Station("A", 1)))
+
+ table.toggleSort("qrb")
+ assertEquals(listOf("A", "B"), table.visibleRows.map { it.callSign })
+
+ table.toggleSort("qrb")
+ assertEquals(listOf("B", "A"), table.visibleRows.map { it.callSign })
+
+ table.toggleSort("qrb")
+ assertNull(table.sortedColumnId)
+ assertEquals(listOf("B", "A"), table.visibleRows.map { it.callSign }, "arrival order not restored")
+ }
+
+ /** After clearing, the next click on the same column starts ascending again. */
+ @Test
+ fun `sorting the same column after clearing starts over ascending`() {
+ val table = stationTable()
+ table.replaceRows(listOf(Station("B", 2), Station("A", 1)))
+
+ table.toggleSort("qrb")
+ table.toggleSort("qrb")
+ table.toggleSort("qrb")
+ table.toggleSort("qrb")
+
+ assertEquals("qrb", table.sortedColumnId)
+ assertTrue(table.sortAscending)
+ assertEquals(listOf("A", "B"), table.visibleRows.map { it.callSign })
+ }
+
+ /** Switching to another column is a fresh ascending sort, not a continuation. */
+ @Test
+ fun `switching columns does not inherit the cycle`() {
+ val table = stationTable()
+ table.replaceRows(listOf(Station("B", 2), Station("A", 1)))
+
+ table.toggleSort("qrb")
+ table.toggleSort("qrb")
+ table.toggleSort("callsign")
+
+ assertEquals("callsign", table.sortedColumnId)
+ assertTrue(table.sortAscending)
+ }
+
+ @Test
+ fun `an unknown column is ignored rather than clearing the sorting`() {
+ val table = stationTable()
+ table.replaceRows(listOf(Station("A", 1)))
+ table.toggleSort("qrb")
+
+ table.toggleSort("a-column-this-release-no-longer-has")
+
+ assertEquals("qrb", table.sortedColumnId,
+ "a stored layout naming a dropped column must not undo the sorting")
+ }
+
+ @Test
+ fun `the sorted order is computed once, not on every read`() {
+ /*
+ * visibleRows is read on every recomposition. Sorting there means sorting up
+ * to thirty thousand rows per inbound message, on the thread that draws, and
+ * a column without a comparator would call its value function once per
+ * comparison on top of that.
+ */
+ var valueCalls = 0
+ val table = DataTableState(
+ columns = listOf(
+ DataColumn(
+ id = "callsign",
+ title = "Callsign",
+ value = { s: Station -> valueCalls++; s.callSign },
+ ),
+ ),
+ rowKey = RowKeys.byValue { s: Station -> s.callSign },
+ )
+ table.replaceRows(listOf(Station("C", 1), Station("A", 2), Station("B", 3)))
+ table.toggleSort("callsign")
+
+ table.visibleRows
+ val afterFirstRead = valueCalls
+ repeat(20) { table.visibleRows }
+
+ assertEquals(afterFirstRead, valueCalls,
+ "reading the sorted rows again must not sort them again")
+ }
+
+ @Test
+ fun `rows read once and then replaced are the new rows`() {
+ /*
+ * The regression this pins: caching the sorted order in a plain field meant
+ * that once the cache was warm the getter never read `rows` again, so nothing
+ * subscribed to it and the table went on showing what it had. On screen that
+ * looked like a monitor window that stayed empty while the JavaFX tab beside it
+ * filled up, and only redrew when an unrelated click forced it to.
+ */
+ val table = stationTable()
+ table.replaceRows(listOf(Station("DN9APW", 1)))
+ table.toggleSort("callsign")
+
+ assertEquals(listOf("DN9APW"), table.visibleRows.map { it.callSign })
+
+ table.replaceRows(listOf(Station("DN9APW", 1), Station("DO5AMF", 2)))
+
+ assertEquals(listOf("DN9APW", "DO5AMF"), table.visibleRows.map { it.callSign },
+ "a row that arrived after the first read has to be there")
+ }
+
+ @Test
+ fun `reading the rows subscribes to them even when the order is already computed`() {
+ /*
+ * This is the defect a value-checking test cannot see. Compose repaints a
+ * composable when state it *read* changes. A cache that returns early stops
+ * reading `rows`, so the next recomposition — triggered by anything else —
+ * leaves the composable subscribed to nothing, and later rows never arrive on
+ * screen. That is what a monitor window sitting empty beside a filling JavaFX
+ * tab looks like, redrawing only when an unrelated click forces it.
+ */
+ val table = stationTable()
+ table.replaceRows(listOf(Station("DN9APW", 1)))
+ table.toggleSort("callsign")
+ table.visibleRows
+
+ var reads = 0
+ Snapshot.observe(readObserver = { reads++ }) { table.visibleRows }
+
+ assertTrue(reads > 0,
+ "a read that touches no state means the table will not repaint when the "
+ + "rows change")
+ }
+
+ @Test
+ fun `changing the sorting after a read re-sorts`() {
+ val table = stationTable()
+ table.replaceRows(listOf(Station("B", 1), Station("A", 2)))
+ table.toggleSort("callsign")
+ assertEquals(listOf("A", "B"), table.visibleRows.map { it.callSign })
+
+ table.toggleSort("callsign")
+
+ assertEquals(listOf("B", "A"), table.visibleRows.map { it.callSign })
+ }
+
+ @Test
+ fun `unsorted rows stay in the order they were given`() {
+ val table = stationTable()
+ table.replaceRows(listOf(Station("C", 3), Station("A", 1), Station("B", 2)))
+
+ assertEquals(listOf("C", "A", "B"), table.visibleRows.map { it.callSign })
+ }
+
+ @Test
+ fun `two identical messages are two rows, not one`() {
+ /*
+ * The reason event lists need reference identity: a key built from their
+ * fields merges them, and repeating a macro is ordinary operator behaviour.
+ */
+ val table = DataTableState(
+ columns = listOf(DataColumn(id = "text", title = "Message", value = { s: Spot -> s.text })),
+ rowKey = RowKeys.byReference(),
+ )
+ val first = Spot("tnx om, 73")
+ val second = Spot("tnx om, 73")
+
+ table.replaceRows(listOf(first, second))
+ table.select(second)
+
+ assertEquals(2, table.visibleRows.size)
+ assertSame(second, table.selectedRow, "the second one, not the first")
+ assertFalse(table.isSelected(first))
+ }
+
+ @Test
+ fun `the key a row is drawn under is its identity, not its position`() {
+ /*
+ * The list draws rows keyed by this. A positional key would reshuffle every
+ * row whenever something arrives at the top, which in a live list is constant.
+ */
+ val table = stationTable()
+ val dn9apw = Station("DN9APW", 100)
+ table.replaceRows(listOf(dn9apw, Station("DO5AMF", 200)))
+
+ val keyAtTop = table.keyOf(dn9apw)
+
+ table.replaceRows(listOf(Station("DL1ABC", 50), dn9apw))
+
+ assertEquals(keyAtTop, table.keyOf(dn9apw), "same row, same key, new position")
+ }
+
+ @Test
+ fun `a reference key stays with its row across a refresh`() {
+ val table = DataTableState(
+ columns = listOf(DataColumn(id = "text", title = "Message", value = { s: Spot -> s.text })),
+ rowKey = RowKeys.byReference(),
+ )
+ val kept = Spot("first")
+ table.replaceRows(listOf(kept))
+ table.select(kept)
+
+ // The roster hands back the same instances plus a new one.
+ table.replaceRows(listOf(Spot("newer"), kept))
+
+ assertSame(kept, table.selectedRow)
+ }
+}
+
+/**
+ * Column widths survive a restart, and the table is what remembers them.
+ *
+ * Storage is ChatPreferences.getTableColumnWidth/setTableColumnWidth, which is
+ * toolkit-free; only the JavaFX glue around it stays behind. The ids are part of the
+ * stored layout, so this is also where a renamed column loses an operator's sizing.
+ */
+class DataTableColumnWidthTest {
+
+ private class Station(val callSign: String)
+
+ private class RecordingStore : ColumnWidthStore {
+ val stored = mutableMapOf()
+ var saveRequests = 0
+
+ override fun width(tableId: String, columnId: String): Double? = stored["$tableId/$columnId"]
+
+ override fun setWidth(tableId: String, columnId: String, width: Double) {
+ stored["$tableId/$columnId"] = width
+ }
+
+ override fun requestSave() {
+ saveRequests++
+ }
+ }
+
+ private fun table(store: ColumnWidthStore) = DataTableState(
+ columns = listOf(
+ DataColumn(id = "callsign", title = "Callsign", value = { s: Station -> s.callSign }),
+ DataColumn(id = "qra", title = "QRA", value = { _: Station -> "" }),
+ ),
+ rowKey = RowKeys.byValue { s: Station -> s.callSign },
+ tableId = "dx-cluster-monitor",
+ widths = store,
+ )
+
+ @Test
+ fun `a stored width is used`() {
+ val store = RecordingStore()
+ store.stored["dx-cluster-monitor/callsign"] = 140.0
+
+ assertEquals(140.0, table(store).widthOf("callsign"))
+ }
+
+ @Test
+ fun `a column never sized yet has no stored width`() {
+ assertNull(table(RecordingStore()).widthOf("qra"),
+ "the caller falls back to the column's own weight, rather than to a "
+ + "number invented here")
+ }
+
+ @Test
+ fun `resizing stores the width and asks for a save`() {
+ val store = RecordingStore()
+ val table = table(store)
+
+ table.resizeColumn("callsign", 180.0)
+
+ assertEquals(180.0, table.widthOf("callsign"))
+ assertEquals(1, store.saveRequests,
+ "saving is debounced by the layout autosave, not written per pixel")
+ }
+
+ @Test
+ fun `resizing a column this table does not have is ignored`() {
+ val store = RecordingStore()
+ val table = table(store)
+
+ table.resizeColumn("a-column-from-another-table", 180.0)
+
+ assertTrue(store.stored.isEmpty())
+ assertEquals(0, store.saveRequests)
+ }
+
+ @Test
+ fun `a width at or below zero is refused`() {
+ val store = RecordingStore()
+ val table = table(store)
+ table.resizeColumn("callsign", 120.0)
+
+ table.resizeColumn("callsign", 0.0)
+
+ assertEquals(120.0, table.widthOf("callsign"),
+ "a drag past the left edge must not store a column the operator cannot "
+ + "grab again")
+ }
+
+ @Test
+ fun `a table without a width store simply has no stored widths`() {
+ val table = DataTableState(
+ columns = listOf(DataColumn(id = "c", title = "C", value = { _: Station -> "" })),
+ rowKey = RowKeys.byValue { s: Station -> s.callSign },
+ )
+
+ table.resizeColumn("c", 100.0)
+
+ assertNull(table.widthOf("c"), "the two monitor tables may be built before "
+ + "preferences are reachable; that must not throw")
+ }
+}
+
+/**
+ * The reference keys must not turn into a memory leak, and they are handed out from
+ * two threads at once.
+ *
+ * Both stores behind these tables are capped on purpose — ten thousand cluster
+ * messages, thirty thousand chat messages — so that memory cannot grow without bound.
+ * A key map holding a strong reference to every row it ever saw would undo that cap
+ * from the outside.
+ */
+class ReferenceRowKeysTest {
+
+ private class Spot(val text: String)
+
+ private fun table(keys: RowKeySource) = DataTableState(
+ columns = listOf(DataColumn(id = "text", title = "Message", value = { s: Spot -> s.text })),
+ rowKey = keys,
+ )
+
+ @Test
+ fun `keys of rows that have gone are released`() {
+ val keys = RowKeys.byReference()
+ val table = table(keys)
+ val first = (1..100).map { Spot("message $it") }
+
+ table.replaceRows(first)
+ // Keys are handed out when the list draws, which is what this stands in for.
+ first.forEach { table.keyOf(it) }
+ assertEquals(100, keys.size(), "one key per row on screen")
+
+ // The store trims, as ChatController does to stay inside its cap.
+ val remaining = first.takeLast(11)
+ table.replaceRows(remaining)
+ remaining.forEach { table.keyOf(it) }
+
+ assertEquals(11, keys.size(),
+ "a key map that keeps every row it ever saw undoes the cap the store keeps")
+ }
+
+ @Test
+ fun `a row that stays keeps its key across a refresh`() {
+ val keys = RowKeys.byReference()
+ val table = table(keys)
+ val kept = Spot("kept")
+
+ table.replaceRows(listOf(kept, Spot("other")))
+ val before = keys.keyOf(kept)
+
+ table.replaceRows(listOf(Spot("newer"), kept))
+
+ assertEquals(before, keys.keyOf(kept), "same row, same key")
+ }
+
+ @Test
+ fun `keys handed out from several threads at once are unique`() {
+ // The rows are replaced from the JavaFX thread while Compose draws on its own.
+ val keys = RowKeys.byReference()
+ val rows = (1..500).map { Spot("message $it") }
+ val handedOut = java.util.concurrent.ConcurrentHashMap.newKeySet()
+ val threads = (1..4).map {
+ Thread { rows.forEach { row -> handedOut.add(keys.keyOf(row)) } }
+ }
+
+ threads.forEach { it.start() }
+ threads.forEach { it.join() }
+
+ assertEquals(500, handedOut.size,
+ "two rows sharing a key make the list throw; one row with two keys makes "
+ + "it jump — both were seen in Etappe 2")
+ }
+}
+
+/**
+ * Columns can be dragged into another order, as the JavaFX TableView allowed.
+ *
+ * Within the session only: JavaFX did not persist the order either — TableLayoutManager
+ * stores widths and ChatPreferences has no column order at all — and 1.50 is a
+ * functionally equal port, not an improvement on it.
+ */
+class ColumnOrderTest {
+
+ private class Station(val callSign: String)
+
+ private fun table() = DataTableState(
+ columns = listOf("a", "b", "c").map { id ->
+ DataColumn(id = id, title = id.uppercase(), value = { _: Station -> id })
+ },
+ rowKey = RowKeys.byValue { s: Station -> s.callSign },
+ )
+
+ @Test
+ fun `columns start in the order they were declared`() {
+ assertEquals(listOf("a", "b", "c"), table().orderedColumns.map { it.id })
+ }
+
+ @Test
+ fun `a column dragged to the front lands there`() {
+ val table = table()
+
+ table.moveColumn("c", 0)
+
+ assertEquals(listOf("c", "a", "b"), table.orderedColumns.map { it.id })
+ }
+
+ @Test
+ fun `a column dragged to the end lands there`() {
+ val table = table()
+
+ table.moveColumn("a", 2)
+
+ assertEquals(listOf("b", "c", "a"), table.orderedColumns.map { it.id })
+ }
+
+ @Test
+ fun `a target outside the table is clamped rather than dropping the column`() {
+ val table = table()
+
+ table.moveColumn("b", 99)
+ assertEquals(listOf("a", "c", "b"), table.orderedColumns.map { it.id })
+
+ table.moveColumn("b", -5)
+ assertEquals(listOf("b", "a", "c"), table.orderedColumns.map { it.id })
+ }
+
+ @Test
+ fun `moving a column this table does not have changes nothing`() {
+ val table = table()
+
+ table.moveColumn("a-column-from-another-table", 0)
+
+ assertEquals(listOf("a", "b", "c"), table.orderedColumns.map { it.id })
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/DirectedMessageColumnsTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/DirectedMessageColumnsTest.kt
new file mode 100644
index 00000000..76841931
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/DirectedMessageColumnsTest.kt
@@ -0,0 +1,123 @@
+package kst4contest.view.compose
+
+import kst4contest.model.AirPlane
+import kst4contest.model.AirPlaneReflectionInfo
+import kst4contest.model.ChatCategory
+import kst4contest.model.ChatMember
+import kst4contest.model.ChatMessage
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+/**
+ * The messages addressed to this station. Its own pane above the send line, not one of
+ * the tabs — the operator must be able to see a private call without switching away from
+ * whatever tab they are reading.
+ */
+class DirectedMessageColumnsTest {
+
+ private val columns = DirectedMessageColumns.all { "formatted: ${it.messageText}" }
+
+ /** The stored column widths are keyed by these ids; renaming one loses a setting. */
+ @Test
+ fun `the column ids are the ones the widths are stored under`() {
+ assertEquals(
+ listOf("time", "callsign", "name", "qra", "qrb", "message", "last-qrg", "airscout", "category"),
+ columns.map { it.id },
+ )
+ }
+
+ /** The order operators read left to right, character for character from JavaFX. */
+ @Test
+ fun `the titles are the ones on screen today`() {
+ assertEquals(
+ listOf("Time", "Callsign", "Name", "QRA", "QRB", "Message", "Last known QRG", "AP [minutes / pot%]", "Category"),
+ columns.map { it.title },
+ )
+ }
+
+ @Test
+ fun `a message from a known station fills the sender columns`() {
+ val message = message(sender = member("DL0ABC", name = "Hans", qra = "JO40AA"))
+
+ assertEquals("DL0ABC", cell("callsign", message))
+ assertEquals("Hans", cell("name", message))
+ assertEquals("JO40AA", cell("qra", message))
+ }
+
+ /**
+ * A message whose sender is not in the user list any more still has to render. The
+ * JavaFX cells all guarded for it, and a station leaving the chat mid contest is
+ * normal, not exceptional.
+ */
+ @Test
+ fun `a message without a sender still renders every column`() {
+ val message = message(sender = null)
+ columns.forEach { column ->
+ val value = column.value(message)
+ assertTrue(value.isNotEmpty() || value.isEmpty(), "${column.id} threw")
+ }
+ assertEquals("", cell("callsign", message))
+ assertEquals("", cell("qrb", message))
+ }
+
+ @Test
+ fun `the message text comes from the formatter the controller provides`() {
+ val message = message(sender = member("DL0ABC"), text = "pse 23cm")
+ assertEquals("formatted: pse 23cm", cell("message", message))
+ }
+
+ /** The odd degree sign outside the bracket is JavaFX's; operators read it that way. */
+ @Test
+ fun `the qrb cell carries the distance and the bearing`() {
+ val sender = member("DL0ABC").apply {
+ qrb = 342.7
+ setQTFdirection(88.4)
+ }
+ assertEquals("342 km (88)°", cell("qrb", message(sender = sender)))
+ }
+
+ /** Own messages get no distance to themselves. */
+ @Test
+ fun `a message from the own station has no qrb`() {
+ val sender = member("DN9APW").apply { qrb = 0.0 }
+ val cols = DirectedMessageColumns.all(ownCallSign = { "DN9APW" }) { "x" }
+ val cell = cols.single { it.id == "qrb" }.value(message(sender = sender))
+ assertEquals("", cell)
+ }
+
+ @Test
+ fun `the airscout cell is the shared one`() {
+ val sender = member("DL0ABC").apply {
+ airPlaneReflectInfo = AirPlaneReflectionInfo().apply {
+ risingAirplanes = mutableListOf(
+ AirPlane().apply { arrivingDurationMinutes = 7; potential = 85 },
+ )
+ }
+ }
+ assertEquals("7 (85%)", cell("airscout", message(sender = sender)))
+ }
+
+ @Test
+ fun `the category names the chat the message arrived in`() {
+ val message = message(sender = member("DL0ABC")).apply { chatCategory = ChatCategory(2) }
+ assertTrue(cell("category", message).isNotBlank())
+ }
+
+ private fun cell(id: String, message: ChatMessage): String =
+ columns.single { it.id == id }.value(message)
+
+ private fun message(sender: ChatMember?, text: String = "hello"): ChatMessage =
+ ChatMessage().apply {
+ this.sender = sender
+ messageText = text
+ chatCategory = ChatCategory(2)
+ }
+
+ private fun member(call: String, name: String = "", qra: String = ""): ChatMember =
+ ChatMember().apply {
+ setCallSign(call)
+ this.name = name
+ this.qra = qra
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/DirectedMessageRowStyleTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/DirectedMessageRowStyleTest.kt
new file mode 100644
index 00000000..bdff809e
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/DirectedMessageRowStyleTest.kt
@@ -0,0 +1,75 @@
+package kst4contest.view.compose
+
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertNotEquals
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Test
+
+/**
+ * A new private message is highlighted and the highlight fades over five minutes, so a call
+ * that arrived while the operator was looking elsewhere is still findable — noticing one is
+ * what that pane is for. Own messages get their own colour instead.
+ *
+ * The steps and the colours are PrivateMessageRowStyleResolver's and the stylesheets', which
+ * both designs share.
+ */
+class DirectedMessageRowStyleTest {
+
+ @Test
+ fun `a message just arrived is brightest`() {
+ val accent = DirectedMessageRowStyle.accentFor(ownMessage = false, ageSeconds = 0)
+ assertEquals(DirectedMessageRowStyle.FRESH, accent?.background)
+ }
+
+ /** Six steps, each dimmer than the last, so age is readable without reading the clock. */
+ @Test
+ fun `the highlight fades step by step`() {
+ val ages = listOf(30L, 60L, 90L, 120L, 180L, 300L)
+ val colours = ages.map { DirectedMessageRowStyle.accentFor(false, it)?.background }
+
+ assertEquals(ages.size, colours.distinct().size, "two steps share a colour")
+ colours.forEach { assertNotEquals(null, it) }
+ }
+
+ /** The step boundaries are inclusive, as the resolver's `<=` is. */
+ @Test
+ fun `a boundary age belongs to the brighter step`() {
+ assertEquals(
+ DirectedMessageRowStyle.accentFor(false, 30),
+ DirectedMessageRowStyle.accentFor(false, 29),
+ )
+ assertNotEquals(
+ DirectedMessageRowStyle.accentFor(false, 30),
+ DirectedMessageRowStyle.accentFor(false, 31),
+ )
+ }
+
+ /** After five minutes it is no longer news and the row goes back to normal. */
+ @Test
+ fun `an old message is not highlighted at all`() {
+ assertNull(DirectedMessageRowStyle.accentFor(false, 301))
+ assertNull(DirectedMessageRowStyle.accentFor(false, 60 * 60))
+ }
+
+ /** Own messages are blue at any age: they are context, not news. */
+ @Test
+ fun `an own message keeps its own colour however old`() {
+ assertEquals(DirectedMessageRowStyle.OWN, DirectedMessageRowStyle.accentFor(true, 0)?.background)
+ assertEquals(DirectedMessageRowStyle.OWN, DirectedMessageRowStyle.accentFor(true, 9999)?.background)
+ }
+
+ /** The resolver in the JavaFX view decides the steps; this must not drift from it. */
+ @Test
+ fun `the steps are the resolver's own`() {
+ listOf(0L, 30L, 60L, 90L, 120L, 180L, 300L).forEach { age ->
+ val styleClass = kst4contest.view.PrivateMessageRowStyleResolver
+ .resolveStyleClass(false, age)
+ val accent = DirectedMessageRowStyle.accentFor(false, age)
+ assertEquals(
+ styleClass != null,
+ accent != null,
+ "age $age: the resolver says $styleClass but the accent says $accent",
+ )
+ }
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/EditableListStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/EditableListStateTest.kt
new file mode 100644
index 00000000..c0f69146
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/EditableListStateTest.kt
@@ -0,0 +1,220 @@
+package kst4contest.view.compose
+
+import kst4contest.observe.SimpleRoster
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertNotEquals
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Assertions.assertSame
+import org.junit.jupiter.api.Assertions.assertTrue
+
+class EditableListStateTest {
+
+ @Test
+ fun `a new entry lands at the top and becomes the selection`() {
+ val state = EditableListState(listOf("alt"))
+
+ state.addAtTop("neu")
+
+ assertEquals(listOf("neu", "alt"), state.texts)
+ assertEquals(0, state.selectedIndex,
+ "the JavaFX table selected row 0 and opened it for editing")
+ }
+
+ @Test
+ fun `an entry edited to blank disappears`() {
+ val state = EditableListState(listOf("a", "b", "c"))
+
+ state.updateAt(1, " ")
+
+ assertEquals(listOf("a", "c"), state.texts,
+ "clearing the text was how the operator removed an entry")
+ }
+
+ @Test
+ fun `an edit keeps the other entries and their order`() {
+ val state = EditableListState(listOf("a", "b", "c"))
+
+ state.updateAt(1, "B")
+
+ assertEquals(listOf("a", "B", "c"), state.texts)
+ }
+
+ @Test
+ fun `removing the last entry clears the selection`() {
+ val state = EditableListState(listOf("a"))
+
+ state.select(0)
+ state.removeAt(0)
+
+ assertEquals(emptyList(), state.texts)
+ assertNull(state.selectedIndex)
+ }
+
+ @Test
+ fun `an index outside the list is ignored`() {
+ val state = EditableListState(listOf("a"))
+
+ state.updateAt(5, "x")
+ state.removeAt(-1)
+
+ assertEquals(listOf("a"), state.texts,
+ "a stale index from a pending edit must not corrupt the list")
+ }
+
+ @Test
+ fun `every entry keeps a stable key across an insertion at the top`() {
+ val state = EditableListState(listOf("a", "b"))
+ val keyOfA = state.entries[0].key
+
+ state.addAtTop("neu")
+
+ assertEquals(keyOfA, state.entries[1].key,
+ "a row must keep its key when another is inserted above it, or the "
+ + "selection jumps in a LazyColumn")
+ }
+
+ @Test
+ fun `duplicate texts get distinct keys`() {
+ val state = EditableListState(listOf("gleich", "gleich"))
+
+ assertNotEquals(state.entries[0].key, state.entries[1].key,
+ "keying by text would collapse duplicates into one row")
+ }
+
+ @Test
+ fun `committing fills the existing roster instead of replacing it`() {
+ val roster = SimpleRoster()
+ roster.add("alt")
+ val state = EditableListState(listOf("neu"))
+
+ state.commitTo(roster)
+
+ assertEquals(listOf("neu"), roster.snapshot())
+ assertSame(roster, roster,
+ "replacing the roster would orphan every listener registered on it")
+ }
+
+ @Test
+ fun `a new entry can also be appended`() {
+ val state = EditableListState(listOf("a"))
+
+ state.addAtBottom("b")
+
+ assertEquals(listOf("a", "b"), state.texts,
+ "the monitoring list appended: chatcontroller.getLstNotify_...().add(call)")
+ assertEquals(1, state.selectedIndex)
+ }
+
+ @Test
+ fun `moving the selection down swaps it with the entry below`() {
+ val state = EditableListState(listOf("a", "b", "c"))
+ state.select(0)
+
+ assertTrue(state.moveSelected(1))
+
+ assertEquals(listOf("b", "a", "c"), state.texts)
+ assertEquals(1, state.selectedIndex, "the moved row stays selected")
+ }
+
+ @Test
+ fun `moving the selection up swaps it with the entry above`() {
+ val state = EditableListState(listOf("a", "b", "c"))
+ state.select(2)
+
+ assertTrue(state.moveSelected(-1))
+
+ assertEquals(listOf("a", "c", "b"), state.texts)
+ assertEquals(1, state.selectedIndex)
+ }
+
+ @Test
+ fun `a move past either end changes nothing`() {
+ val state = EditableListState(listOf("a", "b"))
+
+ state.select(0)
+ assertFalse(state.moveSelected(-1))
+
+ state.select(1)
+ assertFalse(state.moveSelected(1))
+
+ assertEquals(listOf("a", "b"), state.texts)
+ }
+
+ @Test
+ fun `a move without a selection changes nothing`() {
+ val state = EditableListState(listOf("a", "b"))
+
+ assertFalse(state.moveSelected(1))
+
+ assertEquals(listOf("a", "b"), state.texts)
+ }
+
+ @Test
+ fun `committing an edit stores the text when no rule objects`() {
+ val state = EditableListState(listOf("a", "b"))
+
+ val outcome = state.commitAt(1, "B")
+
+ assertEquals(CommitOutcome.Stored("B"), outcome)
+ assertEquals(listOf("a", "B"), state.texts)
+ }
+
+ @Test
+ fun `committing a blank text removes the entry`() {
+ val state = EditableListState(listOf("a", "b"))
+
+ val outcome = state.commitAt(1, " ")
+
+ assertEquals(CommitOutcome.Removed, outcome)
+ assertEquals(listOf("a"), state.texts)
+ }
+
+ @Test
+ fun `a rule may rewrite the committed text`() {
+ val upperCase = EntryRule { candidate, _, _ ->
+ if (candidate.isBlank()) CommitOutcome.Removed
+ else CommitOutcome.Stored(candidate.uppercase())
+ }
+ val state = EditableListState(listOf("dn9apw"), upperCase)
+
+ state.commitAt(0, "do5amf")
+
+ assertEquals(listOf("DO5AMF"), state.texts)
+ }
+
+ @Test
+ fun `a refused edit leaves the stored text alone`() {
+ val noB = EntryRule { candidate, _, _ ->
+ if (candidate == "b") CommitOutcome.Refused("no b here") else CommitOutcome.Stored(candidate)
+ }
+ val state = EditableListState(listOf("a"), noB)
+
+ val outcome = state.commitAt(0, "b")
+
+ assertEquals(CommitOutcome.Refused("no b here"), outcome)
+ assertEquals(listOf("a"), state.texts,
+ "the JavaFX table refreshed the cell back to the stored value")
+ }
+
+ @Test
+ fun `a rule sees the other entries but not the row being edited`() {
+ var seen: List? = null
+ val rule = EntryRule { candidate, _, others -> seen = others; CommitOutcome.Stored(candidate) }
+ val state = EditableListState(listOf("a", "b", "c"), rule)
+
+ state.commitAt(1, "b")
+
+ assertEquals(listOf("a", "c"), seen,
+ "the duplicate check skipped the edited row, or renaming it to itself would fail")
+ }
+
+ @Test
+ fun `committing on a vanished row is ignored`() {
+ val state = EditableListState(listOf("a"))
+
+ assertEquals(CommitOutcome.Ignored, state.commitAt(7, "x"))
+ assertEquals(listOf("a"), state.texts)
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/JavaFxStylesheetTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/JavaFxStylesheetTest.kt
new file mode 100644
index 00000000..471626c5
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/JavaFxStylesheetTest.kt
@@ -0,0 +1,186 @@
+package kst4contest.view.compose
+
+import androidx.compose.ui.graphics.Color
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * The colours the Compose windows show come from here, and nothing that renders is
+ * covered by a test. This class is where that gap is closed: the reader takes plain
+ * text and gives plain values.
+ */
+class JavaFxStylesheetTest {
+
+ private fun hex(color: Color): String = String.format(
+ "#%02X%02X%02X",
+ Math.round(color.red * 255),
+ Math.round(color.green * 255),
+ Math.round(color.blue * 255),
+ )
+
+ @Test
+ fun `derive gives the values JavaFX gives`() {
+ /*
+ * Both halves matter. The expected strings were read out of JavaFX and are
+ * asserted outright, so the reader still has to produce them if the delegation
+ * is ever replaced. The cross-check below then proves the delegation itself
+ * still works, which a JavaFX upgrade could break silently: the package is
+ * internal and reached by reflection.
+ */
+ val expected = mapOf(
+ ("#373E43" to 26.4) to "#525B61",
+ ("#373E43" to 35.0) to "#5B646A",
+ ("#373E43" to -20.0) to "#2C3236",
+ ("#ECECEC" to 26.4) to "#F4F4F4",
+ ("#ECECEC" to -20.0) to "#BDBDBD",
+ ("#FFFFFF" to -30.0) to "#B2B2B2",
+ )
+
+ expected.forEach { (input, want) ->
+ val (rgb, percent) = input
+
+ /*
+ * Compared with a tolerance of one step per channel. derive(white, -30%)
+ * lands on 0.7 * 255 = 178.5 exactly, and the two paths round that half
+ * step differently; a test that flips on a half step pins nothing useful.
+ */
+ assertChannelsClose(want, JavaFxStylesheet.derive(parse(rgb), percent),
+ "derive($rgb, $percent%)")
+ assertChannelsClose(want, parse(deriveWithJavaFx(rgb, percent)),
+ "JavaFX itself no longer gives this; the pinned value is stale")
+ }
+ }
+
+ @Test
+ fun `the window background is not the base colour`() {
+ val palette = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css")
+
+ assertEquals("#373E43", hex(palette.base))
+ assertEquals("#525B61", hex(palette.windowBackground),
+ "Modena paints a window with derive(-fx-base, 26.4%), not with -fx-base; "
+ + "taking the base raw made the Compose window visibly darker than the "
+ + "JavaFX one beside it",
+ )
+ }
+
+ @Test
+ fun `the evening sheet gives its declared colours`() {
+ val palette = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css")
+
+ assertEquals("#373E43", hex(palette.base), "-fx-base")
+ assertEquals("#1E74C6", hex(palette.accent), "-fx-accent")
+ assertEquals("#D3D3D3", hex(palette.labelTextFill), ".label -fx-text-fill is lightgray")
+ assertEquals("#3C3C3C", hex(palette.separatorLine), ".separator *.line")
+ assertEquals("#FF0000", hex(palette.buttonPressedBorder), ".button:pressed")
+ assertEquals(12f, palette.fontSizePx)
+ }
+
+ @Test
+ fun `the control interior resolves its reference before deriving`() {
+ val palette = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css")
+
+ assertEquals(
+ hex(JavaFxStylesheet.derive(palette.base, 35.0)),
+ hex(palette.controlInnerBackground),
+ "-fx-control-inner-background is derive(-fx-base, 35%): the reference to "
+ + "-fx-base has to be looked up in the same sheet first",
+ )
+ }
+
+ @Test
+ fun `the accent is the green of the field text, not the declared accent`() {
+ val evening = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css")
+ val day = JavaFxStylesheet.read("/KST4ContestDefaultDay.css")
+
+ assertEquals("#1E74C6", hex(evening.accent),
+ "-fx-accent stays what the sheet says; it drives the JavaFX selection bar")
+
+ // green (#008000) and lightgreen (#90EE90), averaged.
+ assertEquals("#48B748", hex(evening.textAccent),
+ ".text-field .text is what the operator reads as the accent")
+ assertEquals("#48B748", hex(day.textAccent),
+ "both sheets declare the same rule, so the accent does not change with "
+ + "the design")
+ }
+
+ @Test
+ fun `the hover gradient keeps its stops in order`() {
+ val palette = JavaFxStylesheet.read("/KST4ContestDefaultEvening.css")
+
+ assertTrue(palette.buttonHoverGradient.size >= 2,
+ "the sheet declares linear-gradient(#f0ff35, #a9ff00)")
+ assertEquals("#F0FF35", hex(palette.buttonHoverGradient[0]))
+ assertEquals("#A9FF00", hex(palette.buttonHoverGradient[1]))
+ }
+
+ @Test
+ fun `the daylight sheet falls back to Modena`() {
+ val palette = JavaFxStylesheet.read("/KST4ContestDefaultDay.css")
+
+ // It declares only a font size and a few button rules; everything else is
+ // JavaFX's own default theme.
+ assertEquals("#ECECEC", hex(palette.base))
+ assertEquals("#0096C9", hex(palette.accent))
+ assertEquals("#FFFFFF", hex(palette.controlInnerBackground))
+ assertEquals("#000000", hex(palette.labelTextFill))
+ assertEquals(12f, palette.fontSizePx)
+ }
+
+ @Test
+ fun `the daylight sheet still carries its own button rules`() {
+ val palette = JavaFxStylesheet.read("/KST4ContestDefaultDay.css")
+
+ assertEquals("#FF0000", hex(palette.buttonPressedBorder),
+ "the daylight sheet declares .button:pressed too")
+ }
+
+ @Test
+ fun `an absent sheet yields the defaults instead of failing`() {
+ val palette = JavaFxStylesheet.read("/there-is-no-such-sheet.css")
+
+ assertEquals("#ECECEC", hex(palette.base),
+ "a missing stylesheet must not stop a window from opening")
+ }
+
+
+ /** Each channel within one step of the expected colour. */
+ private fun assertChannelsClose(expected: String, actual: Color, message: String) {
+ val want = parse(expected)
+
+ listOf(
+ Triple("red", want.red, actual.red),
+ Triple("green", want.green, actual.green),
+ Triple("blue", want.blue, actual.blue),
+ ).forEach { (channel, wanted, got) ->
+ assertEquals(
+ Math.round(wanted * 255).toDouble(),
+ Math.round(got * 255).toDouble(),
+ 1.0,
+ "$message: $channel",
+ )
+ }
+ }
+
+ private fun deriveWithJavaFx(rgb: String, percent: Double): String {
+ val fxColor = Class.forName("javafx.scene.paint.Color")
+ val utils = Class.forName("com.sun.javafx.util.Utils")
+ val source = fxColor.getMethod("web", String::class.java).invoke(null, rgb)
+ val result = utils
+ .getMethod("deriveColor", fxColor, Double::class.javaPrimitiveType)
+ .invoke(null, source, percent / 100.0)
+
+ return hex(
+ Color(
+ (fxColor.getMethod("getRed").invoke(result) as Double).toFloat(),
+ (fxColor.getMethod("getGreen").invoke(result) as Double).toFloat(),
+ (fxColor.getMethod("getBlue").invoke(result) as Double).toFloat(),
+ )
+ )
+ }
+
+ private fun parse(rgb: String): Color {
+ val value = rgb.removePrefix("#").toLong(16)
+ return Color(0xFF000000L or value)
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/MainMenuStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/MainMenuStateTest.kt
new file mode 100644
index 00000000..9578c1f2
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/MainMenuStateTest.kt
@@ -0,0 +1,99 @@
+package kst4contest.view.compose
+
+import kst4contest.controller.On4KstConnectionState
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * Which menu items can be used, and when.
+ *
+ * A menu item that merely looks usable is a worse place for a stale value than a form
+ * field: the operator presses it in the middle of a contest and nothing happens, or
+ * the wrong thing does. The JavaFX window drove these from onConnectionStateChanged.
+ */
+class MainMenuStateTest {
+
+ @Test
+ fun `disconnected - connect is offered, disconnect is not`() {
+ val menu = MainMenuState()
+
+ menu.connectionState = On4KstConnectionState.DISCONNECTED
+
+ assertTrue(menu.canConnect)
+ assertFalse(menu.canDisconnect)
+ }
+
+ @Test
+ fun `while connecting, connect is no longer offered`() {
+ val menu = MainMenuState()
+
+ menu.connectionState = On4KstConnectionState.AUTHENTICATING
+
+ assertFalse(menu.canConnect, "a second attempt while one is running helps nobody")
+ assertTrue(menu.canDisconnect, "the attempt can be given up")
+ }
+
+ @Test
+ fun `online - disconnect yes, connect no`() {
+ val menu = MainMenuState()
+
+ menu.connectionState = On4KstConnectionState.ONLINE
+
+ assertFalse(menu.canConnect)
+ assertTrue(menu.canDisconnect)
+ }
+
+ @Test
+ fun `the chat actions need a session that is really online`() {
+ val menu = MainMenuState()
+
+ menu.connectionState = On4KstConnectionState.AUTHENTICATING
+ assertFalse(menu.canUseChatActions,
+ "logging in is not being in the chat; /AWAY would go nowhere")
+
+ menu.connectionState = On4KstConnectionState.ONLINE
+ assertTrue(menu.canUseChatActions)
+ }
+
+ @Test
+ fun `switching profiles and leaving are always possible`() {
+ val menu = MainMenuState()
+ menu.connectionState = On4KstConnectionState.DISCONNECTED
+
+ assertTrue(menu.canSwitchProfile)
+ assertTrue(menu.canExit, "an operator must always be able to leave")
+ }
+
+ @Test
+ fun `an unknown state is treated as disconnected`() {
+ val menu = MainMenuState()
+
+ assertTrue(menu.canConnect, "before the first callback there is no session")
+ assertFalse(menu.canDisconnect)
+ }
+
+ /**
+ * The away item's own label is the only place the AFK state is visible at all — there
+ * is no other indicator for it. It names the next action: away when active, active when
+ * away. JavaFX flipped setText() inside the handler; here it follows the state, so it
+ * is right even when the state changes from elsewhere.
+ */
+ @Test
+ fun `the away item says what pressing it will do`() {
+ val state = MainMenuState()
+
+ state.awayFromChat = false
+ assertEquals("Show me as AWAY FROM chat!", state.awayMenuLabel)
+
+ state.awayFromChat = true
+ assertEquals("Show me as ACTIVE in chat!", state.awayMenuLabel)
+ }
+
+ /** Nobody is away before the session exists. */
+ @Test
+ fun `a fresh session is not away`() {
+ assertFalse(MainMenuState().awayFromChat)
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/MainWindowFrameTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/MainWindowFrameTest.kt
new file mode 100644
index 00000000..abad25e4
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/MainWindowFrameTest.kt
@@ -0,0 +1,121 @@
+package kst4contest.view.compose
+
+import kst4contest.controller.On4KstConnectionState
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+/**
+ * The frame around the main window: how big it opens, and what its title says.
+ *
+ * Pure arithmetic and string building, which is why it is tested here rather than looked
+ * at. The clamp exists because a window remembered from a larger monitor opens
+ * unusable — its buttons off-screen, and no way to reach them.
+ */
+class MainWindowFrameTest {
+
+ @Test
+ fun `a remembered size is used as it is when it fits`() {
+ val size = MainWindowFrame.startupSize(
+ stored = doubleArrayOf(768.0, 1234.0),
+ screenHeight = 1440.0,
+ screenWidth = 2560.0,
+ )
+ assertEquals(768.0, size.heightDp)
+ assertEquals(1234.0, size.widthDp)
+ }
+
+ /** The margin leaves room for the window decoration and the screen edge. */
+ @Test
+ fun `a window remembered from a larger screen is cut down to fit`() {
+ val size = MainWindowFrame.startupSize(
+ stored = doubleArrayOf(1400.0, 3000.0),
+ screenHeight = 1080.0,
+ screenWidth = 1920.0,
+ )
+ assertEquals(1040.0, size.heightDp)
+ assertEquals(1880.0, size.widthDp)
+ }
+
+ @Test
+ fun `no remembered size at all opens at the default`() {
+ val size = MainWindowFrame.startupSize(null, screenHeight = 1440.0, screenWidth = 2560.0)
+ assertEquals(768.0, size.heightDp)
+ assertEquals(1234.0, size.widthDp)
+ }
+
+ @Test
+ fun `a half written size falls back per dimension`() {
+ val size = MainWindowFrame.startupSize(
+ stored = doubleArrayOf(0.0, 1500.0),
+ screenHeight = 1440.0,
+ screenWidth = 2560.0,
+ )
+ assertEquals(768.0, size.heightDp, "a stored zero height should fall back")
+ assertEquals(1500.0, size.widthDp, "a usable stored width should survive")
+ }
+
+ @Test
+ fun `an unusable stored array falls back entirely`() {
+ assertEquals(768.0, MainWindowFrame.startupSize(doubleArrayOf(600.0), 1440.0, 2560.0).heightDp)
+ assertEquals(
+ 768.0,
+ MainWindowFrame.startupSize(doubleArrayOf(Double.NaN, Double.NaN), 1440.0, 2560.0).heightDp,
+ )
+ }
+
+ /** A screen smaller than the margin must still yield a window with a size. */
+ @Test
+ fun `an absurdly small screen still yields a positive size`() {
+ val size = MainWindowFrame.startupSize(doubleArrayOf(768.0, 1234.0), 20.0, 20.0)
+ assertTrue(size.heightDp > 0.0)
+ assertTrue(size.widthDp > 0.0)
+ }
+
+ // ---- the title ---------------------------------------------------------
+
+ @Test
+ fun `the title carries the chat state`() {
+ assertEquals(
+ "Connected to: 2 as DN9APW",
+ MainWindowFrame.title(chatState = "Connected to: 2 as DN9APW", profileName = null),
+ )
+ }
+
+ /** The root profile is the only one, so naming it would only add noise. */
+ @Test
+ fun `the root profile is not named in the title`() {
+ assertEquals("DISCONNECTED!", MainWindowFrame.title("DISCONNECTED!", profileName = null))
+ }
+
+ @Test
+ fun `a named profile is appended so two windows can be told apart`() {
+ assertEquals(
+ "DISCONNECTED! - Contest station",
+ MainWindowFrame.title("DISCONNECTED!", profileName = "Contest station"),
+ )
+ }
+
+ // ---- the chat state line ----------------------------------------------
+
+ /**
+ * What the title says while the link is not up. The operator reads this to tell a
+ * scheduled reconnect from a dead session, so the wording is pinned.
+ */
+ @Test
+ fun `every connection state has its own offline wording`() {
+ assertEquals("CONNECTION LOST – reconnect scheduled", MainWindowFrame.offlineChatState(On4KstConnectionState.RECONNECT_WAIT))
+ assertEquals("Connecting to ON4KST…", MainWindowFrame.offlineChatState(On4KstConnectionState.CONNECTING))
+ assertEquals("Connected – authenticating with ON4KST…", MainWindowFrame.offlineChatState(On4KstConnectionState.WAITING_FOR_LOGIN_PROMPT))
+ assertEquals("Connected – authenticating with ON4KST…", MainWindowFrame.offlineChatState(On4KstConnectionState.AUTHENTICATING))
+ assertEquals("Connected – synchronizing ON4KST chat data…", MainWindowFrame.offlineChatState(On4KstConnectionState.SYNCING_MAIN_CHAT))
+ assertEquals("Connected – synchronizing ON4KST chat data…", MainWindowFrame.offlineChatState(On4KstConnectionState.SYNCING_SECOND_CHAT))
+ assertEquals("DISCONNECTED!", MainWindowFrame.offlineChatState(On4KstConnectionState.DISCONNECTED))
+ assertEquals("DISCONNECTED!", MainWindowFrame.offlineChatState(On4KstConnectionState.STOPPING))
+ }
+
+ @Test
+ fun `no connection state at all reads as disconnected`() {
+ assertEquals("DISCONNECTED!", MainWindowFrame.offlineChatState(null))
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/MainWindowStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/MainWindowStateTest.kt
new file mode 100644
index 00000000..f1288a96
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/MainWindowStateTest.kt
@@ -0,0 +1,173 @@
+package kst4contest.view.compose
+
+import androidx.compose.runtime.snapshots.Snapshot
+import kst4contest.controller.On4KstConnectionState
+import kst4contest.model.ChatPreferences
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+/**
+ * The shortcut and snippet rosters are ChatPreferences.SimpleRoster, which notifies
+ * nothing Compose can see. Mirroring them into Compose state is what makes the button row
+ * above the send line follow the settings window — the reason ShortcutsTabState has been
+ * calling refreshShortcutButtons into thin air since Etappe 3b.
+ */
+class MainWindowStateRosterTest {
+
+ @Test
+ fun `the shortcuts start out as what the profile holds`() {
+ val prefs = ChatPreferences()
+ prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG", "pse sked"))
+
+ val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
+
+ assertEquals(listOf("MYQRG", "pse sked"), mirror.entries)
+ }
+
+ /**
+ * The roster is written through by the settings window on every committed edit. The
+ * mirror only catches up when it is told to, which is what the refresh hook is for.
+ */
+ @Test
+ fun `a roster change reaches the mirror when it is refreshed`() {
+ val prefs = ChatPreferences()
+ prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG"))
+ val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
+
+ prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG", "SECONDQRG", "qrz?"))
+ assertEquals(listOf("MYQRG"), mirror.entries, "the mirror moved without being refreshed")
+
+ mirror.refresh()
+ assertEquals(listOf("MYQRG", "SECONDQRG", "qrz?"), mirror.entries)
+ }
+
+ @Test
+ fun `an emptied roster empties the mirror`() {
+ val prefs = ChatPreferences()
+ prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG"))
+ val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
+
+ prefs.getLst_txtShortCutBtnList().clear()
+ mirror.refresh()
+
+ assertTrue(mirror.entries.isEmpty())
+ }
+
+ /**
+ * The button row is drawn from this, so reading it has to register as a read — the
+ * failure that produced most of this migration's invisible defects.
+ */
+ @Test
+ fun `drawing the buttons subscribes to the mirror`() {
+ val prefs = ChatPreferences()
+ prefs.getLst_txtShortCutBtnList().setAll(listOf("MYQRG"))
+ val mirror = RosterMirror(prefs.getLst_txtShortCutBtnList())
+
+ val read = mutableSetOf()
+ Snapshot.observe(readObserver = { read += it.toString() }) {
+ mirror.entries.forEach { it.length }
+ }
+
+ assertTrue(read.isNotEmpty(), "the mirror was read without subscribing")
+ }
+}
+
+/**
+ * The splitters of the main window against the positions the profile actually stores.
+ *
+ * A mismatch here is silent and total: SplitterState falls back to an even split, so
+ * every operator would lose the layout they arranged without any error saying so.
+ */
+class MainWindowSplitterDefaultsTest {
+
+ private val prefs = ChatPreferences()
+
+ @Test
+ fun `the message column has as many dividers as the profile stores positions`() {
+ val stored = prefs.getGUImessageSectionSplitpane_dividerposition()
+ assertEquals(
+ MainWindowState.MESSAGE_PANE_COUNT - 1,
+ stored.size,
+ "the message splitter has ${MainWindowState.MESSAGE_PANE_COUNT} panes but the " +
+ "profile stores ${stored.size} positions",
+ )
+
+ val splitter = SplitterState(MainWindowState.MESSAGE_PANE_COUNT, stored) {}
+ assertEquals(
+ stored.map { it.toFloat() },
+ splitter.positions,
+ "the stored positions were rejected and silently replaced by an even split",
+ )
+ }
+
+ @Test
+ fun `the station column has as many dividers as the profile stores positions`() {
+ val stored = prefs.getGUImainWindowRightSplitPane_dividerposition()
+ assertEquals(MainWindowState.RIGHT_PANE_COUNT - 1, stored.size)
+
+ val splitter = SplitterState(MainWindowState.RIGHT_PANE_COUNT, stored) {}
+ assertEquals(stored.map { it.toFloat() }, splitter.positions)
+ }
+
+ @Test
+ fun `the outer splitter takes the one position the profile stores`() {
+ val stored = prefs.getGUImainWindowLeftSplitPane_dividerposition()
+ assertEquals(1, stored.size)
+
+ val splitter = SplitterState(paneCount = 2, stored = stored) {}
+ assertEquals(stored.map { it.toFloat() }, splitter.positions)
+ }
+}
+
+/**
+ * What the window draws about its surroundings — the link, and which other windows are
+ * open — has to be Compose state as well.
+ *
+ * These come from the controller and from AtomicBooleans inside the window hosts, none of
+ * which Compose can see. Reading them through a plain lambda at composition time draws them
+ * once: the badge would freeze on whatever the link was when the window opened, and the
+ * menu's "hide options" would keep saying the wrong thing.
+ */
+class MainWindowSurroundingsTest {
+
+ @Test
+ fun `the connection state starts disconnected and follows what it is told`() {
+ val state = MainWindowSurroundings()
+
+ assertEquals(On4KstConnectionState.DISCONNECTED, state.connectionState)
+
+ state.connectionState = On4KstConnectionState.ONLINE
+ assertEquals(On4KstConnectionState.ONLINE, state.connectionState)
+ }
+
+ @Test
+ fun `drawing the surroundings subscribes to every value`() {
+ val state = MainWindowSurroundings()
+ val read = mutableSetOf()
+
+ Snapshot.observe(readObserver = { read += it.toString() }) {
+ state.connectionState
+ state.connectionDetail
+ state.settingsWindowOpen
+ state.monitorWindowOpen
+ }
+
+ assertEquals(4, read.size, "a value was read without subscribing: $read")
+ }
+
+ /**
+ * The menu and the badge must never disagree about the link — they are two renderings of
+ * one fact, and an operator comparing them would not know which to believe.
+ */
+ @Test
+ fun `the menu follows the same connection state as the badge`() {
+ val menu = MainMenuState()
+ val state = MainWindowSurroundings(menu)
+
+ state.connectionState = On4KstConnectionState.ONLINE
+
+ assertEquals(On4KstConnectionState.ONLINE, menu.connectionState)
+ assertTrue(menu.canUseChatActions)
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/MonitorColumnsTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/MonitorColumnsTest.kt
new file mode 100644
index 00000000..3b910c97
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/MonitorColumnsTest.kt
@@ -0,0 +1,133 @@
+package kst4contest.view.compose
+
+import kst4contest.model.ChatCategory
+import kst4contest.model.ChatMember
+import kst4contest.model.ChatMessage
+import kst4contest.model.ClusterMessage
+import kst4contest.observe.SimpleValue
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertDoesNotThrow
+
+/**
+ * The columns of the two monitor tables.
+ *
+ * Two things are worth pinning. The ids are part of the operator's stored layout, so
+ * renaming one silently loses their column widths. And every value has to survive a
+ * half-filled message: these tables are fed straight from the wire, and a
+ * NullPointerException in a live table during a contest is the worst possible moment.
+ */
+class MonitorColumnsTest {
+
+ @Test
+ fun `the DX cluster columns keep the ids the stored layout uses`() {
+ assertEquals(
+ listOf("time", "call-tx", "locator-tx", "call-rx", "locator-rx", "qrg", "message", "worked"),
+ MonitorColumns.dxCluster().map { it.id },
+ )
+ }
+
+ @Test
+ fun `the DX cluster columns keep their headings`() {
+ assertEquals(
+ listOf("Time", "Call tx", "LOC tx", "Call rx", "LOC rx", "QRG", "Message", "wkd"),
+ MonitorColumns.dxCluster().map { it.title },
+ )
+ }
+
+ @Test
+ fun `a DX cluster row without sender or receiver reads as empty, not as a crash`() {
+ val bare = ClusterMessage()
+
+ MonitorColumns.dxCluster().forEach { column ->
+ assertDoesNotThrow({ column.value(bare) }, "column ${column.id}")
+ }
+ }
+
+ @Test
+ fun `the worked column shows a cross, as the JavaFX table did`() {
+ val worked = ClusterMessage().apply { isReceiverWkd = true }
+ val notWorked = ClusterMessage()
+
+ val column = MonitorColumns.dxCluster().first { it.id == "worked" }
+
+ assertEquals("X", column.value(worked))
+ assertEquals("", column.value(notWorked))
+ }
+
+ @Test
+ fun `the QSO monitor keeps the nine columns the JavaFX table actually showed`() {
+ // Ten were built there and nine added; the "Name" column was never shown.
+ assertEquals(
+ listOf("time", "call-tx", "last-qrg-tx", "worked-tx", "call-rx", "last-qrg-rx",
+ "worked-rx", "message", "category"),
+ MonitorColumns.qsoOfTheOther().map { it.id },
+ )
+ }
+
+ @Test
+ fun `a QSO monitor row without sender or receiver reads as empty, not as a crash`() {
+ val bare = ChatMessage()
+
+ MonitorColumns.qsoOfTheOther().forEach { column ->
+ assertDoesNotThrow({ column.value(bare) }, "column ${column.id}")
+ }
+ }
+
+ @Test
+ fun `the time column shows a clock time, not the epoch off the wire`() {
+ // The server sends epoch seconds; the JavaFX cell ran them through
+ // Utils4KST.time_convertEpochToReadable, which is H:mm:ss in Etc/UTC.
+ val spot = ClusterMessage().apply { timeGenerated = "1759000000" }
+
+ val column = MonitorColumns.dxCluster().first { it.id == "time" }
+
+ assertEquals("19:06:40", column.value(spot))
+ }
+
+ @Test
+ fun `an unreadable time is shown as it came rather than throwing`() {
+ val spot = ClusterMessage().apply { timeGenerated = "not a number" }
+
+ val column = MonitorColumns.dxCluster().first { it.id == "time" }
+
+ assertEquals("not a number", column.value(spot))
+ }
+
+ @Test
+ fun `the QSO time column is formatted the same way`() {
+ val message = ChatMessage().apply { messageGeneratedTime = "1759000000" }
+
+ val column = MonitorColumns.qsoOfTheOther().first { it.id == "time" }
+
+ assertEquals("19:06:40", column.value(message))
+ }
+
+ @Test
+ fun `the cluster QRG is padded the way the main window pads it`() {
+ val spot = ClusterMessage().apply {
+ receiver = ChatMember().apply { frequency = SimpleValue("144.3") }
+ }
+
+ val column = MonitorColumns.dxCluster().first { it.id == "qrg" }
+
+ assertEquals("144.300", column.value(spot),
+ "the same column in the main window pads to three places; two tables of "
+ + "the same data must not disagree")
+ }
+
+ @Test
+ fun `the category comes from the sender, as it did in the JavaFX table`() {
+ val sender = ChatMember().apply { chatCategory = ChatCategory(2) }
+ val message = ChatMessage().apply { this.sender = sender }
+
+ val column = MonitorColumns.qsoOfTheOther().first { it.id == "category" }
+
+ assertEquals(
+ sender.chatCategory.getChatCategoryName(sender.chatCategory.categoryNumber),
+ column.value(message),
+ "the old cell read getSender().getChatCategory(); the message carries a "
+ + "field of the same name that is not always the same value",
+ )
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/ObservedValueTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/ObservedValueTest.kt
new file mode 100644
index 00000000..97e74adc
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/ObservedValueTest.kt
@@ -0,0 +1,93 @@
+package kst4contest.view.compose
+
+import androidx.compose.runtime.snapshots.Snapshot
+import kst4contest.model.ChatPreferences
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+/**
+ * ChatPreferences hands out MutableValue, which notifies its own listeners and nothing
+ * Compose can see. A field drawn straight from one renders once and then goes stale — and
+ * these three in particular move on their own: the rotator writes the QTF, and the
+ * frequency follower writes MYQRG.
+ *
+ * The JavaFX window solved it with attachQtfFollower() and attachOwnQrgFollower(); this is
+ * the same idea, ending in Compose state instead of a TextField.
+ */
+class ObservedValueTest {
+
+ @Test
+ fun `the mirror starts at the stored value`() {
+ val prefs = ChatPreferences()
+ prefs.getMYQRGFirstCat().set("144.370")
+
+ val observed = ObservedValue(prefs.getMYQRGFirstCat())
+
+ assertEquals("144.370", observed.value)
+ }
+
+ /** The rotator moving must reach the screen without anything asking it to. */
+ @Test
+ fun `a change to the underlying value reaches the mirror by itself`() {
+ val prefs = ChatPreferences()
+ prefs.getActualQTF().set(90.0)
+ val observed = ObservedValue(prefs.getActualQTF())
+
+ prefs.getActualQTF().set(135.0)
+
+ assertEquals(135.0, observed.value)
+ }
+
+ @Test
+ fun `a null value is carried through rather than turned into a default`() {
+ val prefs = ChatPreferences()
+ val observed = ObservedValue(prefs.getMYQRGSecondCat())
+
+ prefs.getMYQRGSecondCat().set(null)
+
+ assertEquals(null, observed.value)
+ }
+
+ /**
+ * The field is drawn from this, so reading it has to register as a read. Without this
+ * the whole exercise is pointless: the value would be correct and invisible.
+ */
+ @Test
+ fun `drawing from the mirror subscribes to it`() {
+ val prefs = ChatPreferences()
+ prefs.getMYQRGFirstCat().set("144.370")
+ val observed = ObservedValue(prefs.getMYQRGFirstCat())
+
+ val read = mutableSetOf()
+ Snapshot.observe(readObserver = { read += it.toString() }) {
+ observed.value
+ }
+
+ assertTrue(read.isNotEmpty(), "the mirrored value was read without subscribing")
+ }
+
+ /**
+ * A mirror that is dropped must stop listening. A profile switch builds a new window
+ * over the same preferences, and a leaked listener keeps the old one alive and writing.
+ */
+ @Test
+ fun `releasing the mirror stops it following`() {
+ val prefs = ChatPreferences()
+ prefs.getMYQRGFirstCat().set("144.370")
+ val observed = ObservedValue(prefs.getMYQRGFirstCat())
+
+ observed.release()
+ prefs.getMYQRGFirstCat().set("432.200")
+
+ assertEquals("144.370", observed.value, "a released mirror kept following")
+ }
+
+ @Test
+ fun `releasing twice is harmless`() {
+ val prefs = ChatPreferences()
+ val observed = ObservedValue(prefs.getMYQRGFirstCat())
+ observed.release()
+ observed.release()
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/OperatorProfilePickerStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/OperatorProfilePickerStateTest.kt
new file mode 100644
index 00000000..9bb3902c
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/OperatorProfilePickerStateTest.kt
@@ -0,0 +1,69 @@
+package kst4contest.view.compose
+
+import kst4contest.model.OperatorProfile
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Assertions.assertTrue
+
+class OperatorProfilePickerStateTest {
+
+ private fun profile(id: String, name: String = id, root: Boolean = false, shared: Boolean = false) =
+ OperatorProfile(id, name, root, shared)
+
+ @Test
+ fun `the preselected profile is selected when it is in the list`() {
+ val state = OperatorProfilePickerState(
+ listOf(profile("root"), profile("contest")), preselectedProfileId = "contest")
+
+ assertEquals("contest", state.selected?.profileId)
+ assertTrue(state.canConfirm)
+ }
+
+ @Test
+ fun `an unknown preselection falls back to the first profile`() {
+ val state = OperatorProfilePickerState(
+ listOf(profile("root"), profile("contest")), preselectedProfileId = "gone")
+
+ assertEquals("root", state.selected?.profileId,
+ "a profile that disappeared must not leave the dialog unusable")
+ }
+
+ @Test
+ fun `no preselection falls back to the first profile`() {
+ val state = OperatorProfilePickerState(listOf(profile("root")), preselectedProfileId = null)
+
+ assertEquals("root", state.selected?.profileId)
+ }
+
+ @Test
+ fun `an empty list cannot be confirmed`() {
+ val state = OperatorProfilePickerState(emptyList(), preselectedProfileId = null)
+
+ assertNull(state.selected)
+ assertFalse(state.canConfirm)
+ }
+
+ @Test
+ fun `selecting changes the selection`() {
+ val state = OperatorProfilePickerState(
+ listOf(profile("root"), profile("contest")), preselectedProfileId = "root")
+
+ state.select(profile("contest"))
+
+ assertEquals("contest", state.selected?.profileId)
+ }
+
+ @Test
+ fun `the worked database label matches the JavaFX dialog`() {
+ // The dialog this replaces showed exactly these two strings, and the rule
+ // behind them: the root profile and a sharing profile use the shared file.
+ assertEquals("shared station worked database",
+ workedDatabaseDescription(profile("root", root = true)))
+ assertEquals("shared station worked database",
+ workedDatabaseDescription(profile("sharer", shared = true)))
+ assertEquals("own worked database",
+ workedDatabaseDescription(profile("owner")))
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/PublicMessageColumnsTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/PublicMessageColumnsTest.kt
new file mode 100644
index 00000000..24c2e4e8
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/PublicMessageColumnsTest.kt
@@ -0,0 +1,70 @@
+package kst4contest.view.compose
+
+import kst4contest.model.ChatMember
+import kst4contest.model.ChatMessage
+import kst4contest.observe.SimpleValue
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Assertions.assertDoesNotThrow
+import org.junit.jupiter.api.Assertions.assertEquals
+
+/**
+ * The public message tab, the third of the three below the station list.
+ *
+ * The other two reuse the monitor tables of Etappe 4 under their own layout ids; this
+ * one is its own. Its ids are the operator's stored widths, and its rows come off the
+ * wire half-filled.
+ */
+class PublicMessageColumnsTest {
+
+ @Test
+ fun `the ids are the ones the stored layout uses`() {
+ assertEquals(
+ listOf("time", "callsign", "name", "last-qrg", "message", "category"),
+ PublicMessageColumns.all().map { it.id },
+ )
+ }
+
+ @Test
+ fun `the headings are the ones the operator reads`() {
+ assertEquals(
+ listOf("Time", "Callsign", "Name", "Last QRG", "Message", "Category"),
+ PublicMessageColumns.all().map { it.title },
+ )
+ }
+
+ @Test
+ fun `a message without a sender reads as empty, not as a crash`() {
+ val bare = ChatMessage()
+
+ PublicMessageColumns.all().forEach { column ->
+ assertDoesNotThrow({ column.value(bare) }, "column ${column.id}")
+ }
+ }
+
+ @Test
+ fun `the time is a clock time, as it is in every other table`() {
+ val message = ChatMessage().apply { messageGeneratedTime = "1759000000" }
+
+ assertEquals("19:06:40", PublicMessageColumns.all().first { it.id == "time" }.value(message))
+ }
+
+ @Test
+ fun `callsign, name and QRG come from the sender`() {
+ val message = ChatMessage().apply {
+ sender = ChatMember().apply {
+ callSign = "DN9APW"
+ name = "Philipp"
+ frequency = SimpleValue("144.3")
+ }
+ messageText = "CQ contest"
+ }
+
+ val columns = PublicMessageColumns.all()
+
+ assertEquals("DN9APW", columns.first { it.id == "callsign" }.value(message))
+ assertEquals("Philipp", columns.first { it.id == "name" }.value(message))
+ assertEquals("144.300", columns.first { it.id == "last-qrg" }.value(message),
+ "padded like every other QRG column")
+ assertEquals("CQ contest", columns.first { it.id == "message" }.value(message))
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/SelectedStationStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/SelectedStationStateTest.kt
new file mode 100644
index 00000000..41a83daa
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/SelectedStationStateTest.kt
@@ -0,0 +1,226 @@
+package kst4contest.view.compose
+
+import kst4contest.model.ChatMember
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * The panel below the station list, for the station currently selected.
+ *
+ * Its nine "tag not qrv" marks are written back to the station and propagated to the
+ * active members, so getting one wrong hides a station from every list that filters on
+ * it. The sked controls create a real reminder. Both are worth pinning.
+ */
+class SelectedStationStateTest {
+
+ private class Session {
+ val propagated = mutableListOf()
+ var skedsCreated = 0
+ var lastSkedMinutes: Int? = null
+ var lastSkedMode: String? = null
+ val failMarked = mutableListOf()
+ val failReset = mutableListOf()
+
+ fun state() = SelectedStationState(
+ propagateNotQrv = { propagated.add(it.callSign.orEmpty()) },
+ createSked = { _, minutes, _, mode ->
+ skedsCreated++
+ lastSkedMinutes = minutes
+ lastSkedMode = mode
+ true
+ },
+ markSkedFail = { failMarked.add(it.callSign.orEmpty()) },
+ resetSkedFail = { failReset.add(it.callSign.orEmpty()) },
+ )
+ }
+
+ private fun member(call: String) = ChatMember().apply { callSign = call }
+
+ @Test
+ fun `without a selection there is nothing to act on`() {
+ val state = Session().state()
+
+ assertNull(state.selected)
+ assertFalse(state.canCreateSked, "a sked needs a station to be with")
+ assertFalse(state.canLookUp, "there is no callsign to look up")
+ }
+
+ @Test
+ fun `tagging a band writes it to the station and propagates`() {
+ val session = Session()
+ val state = session.state()
+ val dn9apw = member("DN9APW")
+ state.select(dn9apw)
+
+ state.tagNotQrv(NotQrvBand.B_144, true)
+
+ assertFalse(dn9apw.isQrv144, "not QRV means the QRV flag goes false")
+ assertEquals(listOf("DN9APW"), session.propagated,
+ "every list filtering on QRV has to learn about it")
+ }
+
+ @Test
+ fun `tag not qrv all covers every band`() {
+ val session = Session()
+ val state = session.state()
+ val member = member("DO5AMF")
+ state.select(member)
+
+ state.tagNotQrvAll(true)
+
+ NotQrvBand.entries.forEach { band ->
+ assertTrue(state.isTaggedNotQrv(band), "band ${band.label}")
+ }
+ assertFalse(member.isQrv50)
+ assertFalse(member.isQrv10G)
+ }
+
+ @Test
+ fun `clearing tag not qrv all puts every band back`() {
+ val state = Session().state()
+ val member = member("DO5AMF")
+ state.select(member)
+ state.tagNotQrvAll(true)
+
+ state.tagNotQrvAll(false)
+
+ NotQrvBand.entries.forEach { band ->
+ assertFalse(state.isTaggedNotQrv(band), "band ${band.label}")
+ }
+ assertTrue(member.isQrv144)
+ }
+
+ @Test
+ fun `selecting another station shows that station's tags`() {
+ val state = Session().state()
+ val tagged = member("DN9APW").apply { isQrv432 = false }
+ val untagged = member("DO5AMF")
+
+ state.select(tagged)
+ assertTrue(state.isTaggedNotQrv(NotQrvBand.B_432))
+
+ state.select(untagged)
+ assertFalse(state.isTaggedNotQrv(NotQrvBand.B_432),
+ "the panel shows the selected station, not the one before it")
+ }
+
+ @Test
+ fun `a sked needs a selection and is created once`() {
+ val session = Session()
+ val state = session.state()
+ state.select(member("DN9APW"))
+ state.skedMinutes = 10
+
+ assertTrue(state.canCreateSked)
+ assertTrue(state.createSked())
+
+ assertEquals(1, session.skedsCreated)
+ assertEquals(10, session.lastSkedMinutes)
+ }
+
+ @Test
+ fun `creating a sked without a selection does nothing`() {
+ val session = Session()
+ val state = session.state()
+
+ assertFalse(state.createSked())
+ assertEquals(0, session.skedsCreated)
+ }
+
+ @Test
+ fun `the message filter is one choice, not four`() {
+ val state = Session().state()
+
+ state.messageFilter = SelectedMessageFilter.PM_TO_ME
+ assertEquals(SelectedMessageFilter.PM_TO_ME, state.messageFilter)
+
+ state.messageFilter = SelectedMessageFilter.PUBLIC
+ assertEquals(SelectedMessageFilter.PUBLIC, state.messageFilter,
+ "the JavaFX radio buttons were one toggle group; two cannot be on")
+ }
+
+ /**
+ * 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,
+ * and starting on a filter would silently hide messages the operator never asked to
+ * hide.
+ */
+ @Test
+ fun `no message filter is chosen to begin with`() {
+ assertNull(Session().state().messageFilter)
+ }
+
+ @Test
+ fun `the lookup addresses carry the selected callsign`() {
+ val state = Session().state()
+ state.select(member("DN9APW"))
+
+ assertTrue(state.qrzComUrl().endsWith("DN9APW"))
+ assertTrue(state.qrzCqUrl().endsWith("DN9APW"))
+ }
+
+ @Test
+ fun `the sked offers the minutes the JavaFX choice offered`() {
+ assertEquals(
+ listOf(2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 20),
+ SelectedStationState.SKED_MINUTES,
+ )
+ assertEquals(5, Session().state().skedMinutes, "the default it selected")
+ }
+
+ @Test
+ fun `the sked offers the two modes`() {
+ assertEquals(listOf("SSB", "CW"), SelectedStationState.SKED_MODES)
+ }
+
+ @Test
+ fun `the reminder offers the three offset patterns`() {
+ assertEquals(listOf("2+1", "5+2+1", "10+5+2+1"), SelectedStationState.REMINDER_OFFSETS)
+ }
+
+ @Test
+ fun `the chosen mode reaches the sked`() {
+ val session = Session()
+ val state = session.state()
+ state.select(member("DN9APW"))
+ state.skedMode = "CW"
+
+ state.createSked()
+
+ assertEquals("CW", session.lastSkedMode)
+ }
+
+ @Test
+ fun `marking a sked as failed names the selected station`() {
+ val session = Session()
+ val state = session.state()
+ state.select(member("DN9APW"))
+
+ state.markSkedFail()
+
+ assertEquals(listOf("DN9APW"), session.failMarked)
+ }
+
+ @Test
+ fun `resetting the failure names it too`() {
+ val session = Session()
+ val state = session.state()
+ state.select(member("DO5AMF"))
+
+ state.resetSkedFail()
+
+ assertEquals(listOf("DO5AMF"), session.failReset)
+ }
+
+ @Test
+ fun `marking a failure without a selection does nothing`() {
+ val session = Session()
+
+ session.state().markSkedFail()
+
+ assertTrue(session.failMarked.isEmpty())
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/SettingsFieldCoverageTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/SettingsFieldCoverageTest.kt
new file mode 100644
index 00000000..7c9c37cd
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/SettingsFieldCoverageTest.kt
@@ -0,0 +1,44 @@
+package kst4contest.view.compose
+
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Assertions.assertTrue
+import java.io.File
+
+/**
+ * Guards against a setting quietly disappearing during the port.
+ *
+ * The JavaFX settings window called these ChatPreferences setters. Every one of
+ * them must still be called from the Compose settings sources, or an operator
+ * loses a setting they could configure before — and nothing about that fails to
+ * compile.
+ *
+ * The list was derived mechanically: the setters called between settingsStage and
+ * the Profiles tab, intersected with the setters ChatPreferences actually declares,
+ * so JavaFX control setters like setDisable or setPrefWidth are excluded.
+ *
+ * This checks text occurrence, not runtime behaviour. It cannot tell whether a
+ * setter is called with the right value, only that it is called at all. That is
+ * little, but it is the only check that covers all of them mechanically.
+ */
+class SettingsFieldCoverageTest {
+
+ @Test
+ fun `every setting the JavaFX window could write is still written`() {
+ val expected = File("src/test/resources/settings-fields.txt")
+ .readLines().map { it.trim() }.filter { it.isNotEmpty() }
+
+ val composeDir = File("src/main/kotlin/kst4contest/view/compose")
+ val composeSources = if (composeDir.isDirectory) {
+ composeDir.walkTopDown().filter { it.extension == "kt" }
+ .joinToString("\n") { it.readText() }
+ } else {
+ ""
+ }
+
+ val missing = expected.filterNot { composeSources.contains("$it(") }
+
+ assertTrue(missing.isEmpty(),
+ "settings not written by any Compose source (${missing.size} of ${expected.size}):\n" +
+ missing.joinToString("\n") { " $it" })
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/SettingsForeignWriteCoverageTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/SettingsForeignWriteCoverageTest.kt
new file mode 100644
index 00000000..b293fbe1
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/SettingsForeignWriteCoverageTest.kt
@@ -0,0 +1,50 @@
+package kst4contest.view.compose
+
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Assertions.assertTrue
+import java.io.File
+
+/**
+ * The second net. SettingsFieldCoverageTest only covers ChatPreferences setters,
+ * so a write to any other object slips through it — and the JavaFX settings window
+ * had five such writes:
+ *
+ * - setChatCategoryMain and setChatCategorySecondChat on ChatController, called on
+ * every category selection alongside the ChatPreferences write. Dropping them
+ * leaves the running session on the old category while the stored setting says
+ * otherwise.
+ * - setCallSign, setFrequency and setQra on the own ChatMember, kept in step with
+ * the station fields.
+ *
+ * The list was derived mechanically: the setters called in the settings range that
+ * ChatPreferences does not declare, intersected with the setters the project's own
+ * classes declare, which drops JavaFX control setters like setDisable.
+ */
+class SettingsForeignWriteCoverageTest {
+
+ @Test
+ fun `every write outside ChatPreferences is still performed`() {
+ val expected = File("src/test/resources/settings-foreign-writes.txt")
+ .readLines().map { it.trim() }.filter { it.isNotEmpty() }
+
+ val composeDir = File("src/main/kotlin/kst4contest/view/compose")
+ val sources = if (composeDir.isDirectory) {
+ composeDir.walkTopDown().filter { it.extension == "kt" }
+ .joinToString("\n") { it.readText() }
+ } else {
+ ""
+ }
+
+ /*
+ * A name counts as covered when it appears as a call or as the target of a
+ * function reference. The category writes reach ChatController through the
+ * callbacks StationTabState takes, so the wiring in SettingsWindow is what
+ * satisfies them.
+ */
+ val missing = expected.filterNot { sources.contains("$it(") || sources.contains("::$it") }
+
+ assertTrue(missing.isEmpty(),
+ "writes outside ChatPreferences not performed by any Compose source " +
+ "(${missing.size} of ${expected.size}):\n" + missing.joinToString("\n") { " $it" })
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/SplitterStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/SplitterStateTest.kt
new file mode 100644
index 00000000..f5ff2c75
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/SplitterStateTest.kt
@@ -0,0 +1,225 @@
+package kst4contest.view.compose
+
+import androidx.compose.runtime.snapshots.Snapshot
+import org.junit.jupiter.api.Assertions.assertArrayEquals
+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
+
+/**
+ * Compose brings no SplitPane, so this is ours. An operator arranges the main window
+ * once for their screen and expects it back at the next contest, which makes the stored
+ * divider positions a setting rather than decoration.
+ */
+class SplitterStateTest {
+
+ // ---- what it starts from ----------------------------------------------
+
+ @Test
+ fun `the stored positions are what it starts from`() {
+ val state = SplitterState(paneCount = 2, stored = doubleArrayOf(0.51)) {}
+ assertEquals(listOf(0.51f), state.positions)
+ }
+
+ /**
+ * A profile written by an older version has fewer positions than the window now has
+ * dividers. Falling back beats starting with a window whose panes have no size.
+ */
+ @Test
+ fun `too few stored positions fall back to an even split`() {
+ val state = SplitterState(paneCount = 5, stored = doubleArrayOf(0.62)) {}
+ assertEquals(listOf(0.2f, 0.4f, 0.6f, 0.8f), state.positions)
+ }
+
+ @Test
+ fun `too many stored positions fall back as well`() {
+ val state = SplitterState(paneCount = 2, stored = doubleArrayOf(0.3, 0.6, 0.9)) {}
+ assertEquals(listOf(0.5f), state.positions)
+ }
+
+ @Test
+ fun `positions out of order fall back rather than drawing inside out`() {
+ val state = SplitterState(paneCount = 3, stored = doubleArrayOf(0.8, 0.2)) {}
+ assertEquals(listOf(1f / 3f, 2f / 3f), state.positions)
+ }
+
+ @Test
+ fun `a position outside the pane falls back`() {
+ assertEquals(listOf(0.5f), SplitterState(paneCount = 2, stored = doubleArrayOf(1.4)) {}.positions)
+ assertEquals(listOf(0.5f), SplitterState(paneCount = 2, stored = doubleArrayOf(-0.1)) {}.positions)
+ }
+
+ @Test
+ fun `a single pane has no divider at all`() {
+ val state = SplitterState(paneCount = 1, stored = doubleArrayOf()) {}
+ assertTrue(state.positions.isEmpty())
+ }
+
+ // ---- dragging ----------------------------------------------------------
+
+ @Test
+ fun `dragging a divider moves it`() {
+ val state = SplitterState(paneCount = 2, stored = doubleArrayOf(0.5)) {}
+ state.drag(index = 0, to = 0.7f, totalPx = 1000f)
+ assertEquals(0.7f, state.positions[0], 0.001f)
+ }
+
+ /**
+ * A divider stops at its neighbours. In JavaFX pushing one along shoved the next out
+ * of the way, which is how operators ended up with panes they could not get back.
+ */
+ @Test
+ fun `a divider stops at its neighbour rather than pushing it`() {
+ val state = SplitterState(paneCount = 3, stored = doubleArrayOf(0.3, 0.6)) {}
+ state.drag(index = 0, to = 0.9f, totalPx = 1000f)
+
+ assertTrue(state.positions[0] < state.positions[1], "the first divider passed the second")
+ assertEquals(0.6f, state.positions[1], 0.001f, "the second divider moved")
+ }
+
+ @Test
+ fun `a divider stops at the previous one too`() {
+ val state = SplitterState(paneCount = 3, stored = doubleArrayOf(0.3, 0.6)) {}
+ state.drag(index = 1, to = 0.05f, totalPx = 1000f)
+ assertTrue(state.positions[1] > state.positions[0])
+ }
+
+ /** Every pane keeps a minimum, or a table can be dragged away to nothing by accident. */
+ @Test
+ fun `no pane can be dragged smaller than its minimum`() {
+ val state = SplitterState(paneCount = 2, stored = doubleArrayOf(0.5), minPanePx = 100f) {}
+ state.drag(index = 0, to = 0.01f, totalPx = 1000f)
+ assertEquals(0.1f, state.positions[0], 0.001f)
+ }
+
+ @Test
+ fun `the minimum holds at the far edge as well`() {
+ val state = SplitterState(paneCount = 2, stored = doubleArrayOf(0.5), minPanePx = 100f) {}
+ state.drag(index = 0, to = 0.99f, totalPx = 1000f)
+ assertEquals(0.9f, state.positions[0], 0.001f)
+ }
+
+ /** A window too narrow for the minimums must still lay out, not divide by zero. */
+ @Test
+ fun `an impossibly narrow window still yields usable positions`() {
+ val state = SplitterState(paneCount = 3, stored = doubleArrayOf(0.33, 0.66), minPanePx = 100f) {}
+ state.drag(index = 0, to = 0.5f, totalPx = 50f)
+ assertTrue(state.positions.all { it.isFinite() })
+ assertTrue(state.positions[0] <= state.positions[1])
+ }
+
+ // ---- saving ------------------------------------------------------------
+
+ /**
+ * Saved on release, not on every mouse move. The JavaFX version called
+ * requestLayoutSave() on every pixel of a drag and wrote the profile to disk while
+ * the operator was still holding the mouse down.
+ */
+ @Test
+ fun `dragging saves nothing until the mouse is released`() {
+ var saved: DoubleArray? = null
+ val state = SplitterState(paneCount = 2, stored = doubleArrayOf(0.5)) { saved = it }
+
+ state.drag(index = 0, to = 0.7f, totalPx = 1000f)
+ assertNull(saved, "a drag in progress wrote to the profile")
+
+ state.dragFinished()
+ assertArrayEquals(doubleArrayOf(0.7), saved!!, 0.001)
+ }
+
+ /** A drag that changed nothing must not write the profile either. */
+ @Test
+ fun `a release without a change saves nothing`() {
+ var saves = 0
+ val state = SplitterState(paneCount = 2, stored = doubleArrayOf(0.5)) { saves++ }
+
+ state.dragFinished()
+ assertEquals(0, saves)
+
+ state.drag(index = 0, to = 0.5f, totalPx = 1000f)
+ state.dragFinished()
+ assertEquals(0, saves, "a drag that moved nothing was saved")
+ }
+
+ /** The counterpart of the deferred Etappe 4 minor: a refused move is not a change. */
+ @Test
+ fun `a refused drag is not saved`() {
+ var saves = 0
+ val state = SplitterState(paneCount = 2, stored = doubleArrayOf(0.9), minPanePx = 100f) { saves++ }
+
+ state.drag(index = 0, to = 0.99f, totalPx = 1000f)
+ state.dragFinished()
+ assertEquals(0, saves, "a drag that was clamped back to where it started was saved")
+ }
+
+ // ---- the dividers take room too ---------------------------------------
+
+ /**
+ * The panes and the divider handles are laid out as siblings, so the panes may only
+ * have what is left after the handles. Distributing the full length instead overflows
+ * the splitter by one handle width per divider — 20 dp for the five-pane message
+ * column — and the last pane is clipped off the bottom.
+ */
+ @Test
+ fun `panes and dividers together fit the splitter exactly`() {
+ val state = SplitterState(paneCount = 5, stored = doubleArrayOf(0.62, 0.7, 0.75, 0.9)) {}
+ val total = 800f
+ val handle = 5f
+
+ val available = SplitterState.availableFor(total, dividerPx = handle, paneCount = 5)
+ val panes = state.paneSizes(available)
+
+ assertEquals(
+ total,
+ panes.sum() + handle * 4,
+ 0.01f,
+ "panes plus handles must be the splitter's length",
+ )
+ }
+
+ @Test
+ fun `a splitter with one pane needs no room for dividers`() {
+ assertEquals(500f, SplitterState.availableFor(500f, dividerPx = 5f, paneCount = 1))
+ }
+
+ /** A splitter too small for its own handles must not hand out negative lengths. */
+ @Test
+ fun `a splitter smaller than its handles hands out nothing rather than less than nothing`() {
+ val available = SplitterState.availableFor(10f, dividerPx = 5f, paneCount = 5)
+ assertEquals(0f, available)
+
+ val state = SplitterState(paneCount = 5, stored = doubleArrayOf(0.2, 0.4, 0.6, 0.8)) {}
+ assertTrue(state.paneSizes(available).all { it >= 0f })
+ }
+
+ // ---- what a draw subscribes to ----------------------------------------
+
+ @Test
+ fun `drawing the splitter subscribes to the positions`() {
+ val state = SplitterState(paneCount = 3, stored = doubleArrayOf(0.3, 0.6)) {}
+ val read = mutableSetOf()
+
+ Snapshot.observe(readObserver = { read += it.toString() }) {
+ state.positions.forEach { it.toString() }
+ }
+
+ assertTrue(read.isNotEmpty(), "the positions were read without subscribing")
+ }
+
+ @Test
+ fun `the pane sizes follow from the positions`() {
+ val state = SplitterState(paneCount = 3, stored = doubleArrayOf(0.25, 0.75)) {}
+ assertEquals(listOf(250f, 500f, 250f), state.paneSizes(totalPx = 1000f))
+ }
+
+ @Test
+ fun `the pane sizes always add up to the whole`() {
+ val state = SplitterState(paneCount = 4, stored = doubleArrayOf(0.1, 0.2, 0.9)) {}
+ assertEquals(777f, state.paneSizes(totalPx = 777f).sum(), 0.01f)
+ }
+
+ private fun assertNull(value: Any?, message: String) {
+ assertFalse(value != null, message)
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/StationColumnsTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationColumnsTest.kt
new file mode 100644
index 00000000..815b6720
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationColumnsTest.kt
@@ -0,0 +1,136 @@
+package kst4contest.view.compose
+
+import kst4contest.model.ChatMember
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Assertions.assertDoesNotThrow
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * The 22 columns of the station list.
+ *
+ * The ids are the operator's stored layout — renaming one loses their column widths
+ * without a word — and these rows come from the network, so every value has to survive
+ * a half-filled station.
+ *
+ * "wkdany" sits at the END of the worked group, not the start. Pinned to the JavaFX
+ * table, which adds the band sub-columns under `isStn_bandActiveXX()` and only then
+ * `workedCol.getColumns().add(wkdAny_subcol)` (`Kst4ContestApplication.java:2496`).
+ *
+ * A band cell's text comes from the `formatBand` the caller supplies, because the
+ * JavaFX cell factory distinguishes worked from new from unavailable; the production
+ * caller passes `formatBandCellStatus` (`:10460`).
+ */
+class StationColumnsTest {
+
+ private fun columns() = StationColumns.all(
+ tropoOf = { "" },
+ priorityScoreOf = { 0.0 },
+ formatBand = { _, _, worked -> if (worked) "X" else "" },
+ )
+
+ @Test
+ fun `the ids are the ones the stored layout uses`() {
+ assertEquals(
+ listOf(
+ "callsign", "name", "qra", "qrb", "qtf", "qrg", "airscout", "tropo",
+ "score", "activity", "band-50", "band-70", "band-144", "band-432",
+ "band-1296", "band-2320", "band-3400", "band-5760", "band-10g",
+ "worked-any", "not-qrv", "category",
+ ),
+ columns().map { it.id },
+ )
+ }
+
+ @Test
+ fun `the headings are the ones the operator reads`() {
+ assertEquals(
+ listOf(
+ "Callsign", "Name", "QRA", "QRB", "QTF", "QRG", "AP [minutes / pot%]",
+ "Tropo", "Score", "Act", "50", "70", "144", "432", "23",
+ "13", "9", "6", "3", "wkdany", "NOT QRV @", "Category",
+ ),
+ columns().map { it.title },
+ )
+ }
+
+ @Test
+ fun `the worked columns are grouped under one heading`() {
+ // Without the group, ten columns headed "50 70 144 432 23 13 9 6 3 wkdany"
+ // say nothing about what they mean. The JavaFX table put them under "worked",
+ // and added wkdany last.
+ val grouped = columns().filter { it.group == "worked" }.map { it.id }
+
+ assertEquals(
+ listOf("band-50", "band-70", "band-144", "band-432", "band-1296",
+ "band-2320", "band-3400", "band-5760", "band-10g", "worked-any"),
+ grouped,
+ )
+ }
+
+ @Test
+ fun `columns outside that group carry no group`() {
+ assertNull(columns().first { it.id == "callsign" }.group)
+ assertNull(columns().first { it.id == "category" }.group)
+ }
+
+ @Test
+ fun `a half-filled station reads as empty, not as a crash`() {
+ val bare = ChatMember()
+
+ columns().forEach { column ->
+ assertDoesNotThrow({ column.value(bare) }, "column ${column.id}")
+ }
+ }
+
+ @Test
+ fun `a band column shows a cross only when that band was worked`() {
+ val worked144 = ChatMember().apply { isWorked144 = true }
+
+ val band144 = columns().first { it.id == "band-144" }
+ val band432 = columns().first { it.id == "band-432" }
+
+ assertEquals("X", band144.value(worked144))
+ assertEquals("", band432.value(worked144))
+ }
+
+ @Test
+ fun `the NOT QRV column names the bands the station is not on`() {
+ /*
+ * Every QRV flag starts true — a station is assumed to be on every band until
+ * it says otherwise — so a fresh row leaves this column empty and it fills as
+ * bands are marked off. Listing the QRV bands instead would put a full column
+ * on almost every row and mean the opposite of its heading.
+ */
+ val member = ChatMember().apply {
+ isQrv432 = false
+ isQrv1240 = false
+ }
+
+ val notQrv = columns().first { it.id == "not-qrv" }.value(member)
+
+ assertTrue(notQrv.contains("70cm"), "432 was marked off")
+ assertTrue(notQrv.contains("23cm"), "1240 too")
+ assertTrue(!notQrv.contains("2m"), "144 was never marked off, so it is not listed")
+ }
+
+ @Test
+ fun `a station on every band has an empty NOT QRV column`() {
+ assertEquals("", columns().first { it.id == "not-qrv" }.value(ChatMember()))
+ }
+
+ @Test
+ fun `tropo and score come from outside the station`() {
+ val columns = StationColumns.all(
+ tropoOf = { "12 dB" },
+ priorityScoreOf = { 250.0 },
+ formatBand = { _, _, _ -> "" },
+ )
+ val member = ChatMember()
+
+ assertEquals("12 dB", columns.first { it.id == "tropo" }.value(member),
+ "reachability is computed by the controller, not carried by the row")
+ assertEquals("250", columns.first { it.id == "score" }.value(member))
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/StationFilterStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationFilterStateTest.kt
new file mode 100644
index 00000000..08e9b506
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationFilterStateTest.kt
@@ -0,0 +1,157 @@
+package kst4contest.view.compose
+
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * The filter bar's rules.
+ *
+ * Its 25 toggles are not 25 states. Eighteen switch a predicate in the controller on
+ * or off, eight are one mutually exclusive group that only writes a bearing into the
+ * QTF field, and one colours a cell without filtering anything. Getting that wrong
+ * would put a station in front of the operator that their filter excludes — or hide
+ * one it does not.
+ */
+class StationFilterStateTest {
+
+ @Test
+ fun `nothing is filtered to begin with`() {
+ assertTrue(StationFilterState().activeFilters.isEmpty())
+ }
+
+ @Test
+ fun `a toggle switches exactly its own filter`() {
+ val filters = StationFilterState()
+
+ filters.onlyNewGrids = true
+
+ assertEquals(setOf(StationFilter.ONLY_NEW_GRIDS), filters.activeFilters)
+ }
+
+ @Test
+ fun `grid colouring is not a filter`() {
+ val filters = StationFilterState()
+
+ filters.gridColouring = true
+
+ assertTrue(filters.activeFilters.isEmpty(),
+ "it colours the locator cell; a station must not vanish because of it")
+ assertTrue(filters.gridColouring)
+ }
+
+ @Test
+ fun `each band hides only stations worked on that band`() {
+ val filters = StationFilterState()
+
+ filters.setHideWorkedOn(WorkedBandFilter.B_144, true)
+
+ assertEquals(setOf(StationFilter.HIDE_WORKED_144), filters.activeFilters)
+
+ filters.setHideWorkedOn(WorkedBandFilter.B_432, true)
+
+ assertEquals(
+ setOf(StationFilter.HIDE_WORKED_144, StationFilter.HIDE_WORKED_432),
+ filters.activeFilters,
+ )
+ }
+
+ @Test
+ fun `a bearing button picks one direction and its degrees`() {
+ val filters = StationFilterState()
+
+ filters.toggleBearing(Bearing.NE)
+
+ assertEquals(Bearing.NE, filters.bearing)
+ assertEquals(45.0, filters.qtfDegrees)
+ }
+
+ @Test
+ fun `the bearings are mutually exclusive, as their toggle group was`() {
+ val filters = StationFilterState()
+ filters.toggleBearing(Bearing.N)
+
+ filters.toggleBearing(Bearing.SW)
+
+ assertEquals(Bearing.SW, filters.bearing)
+ assertEquals(225.0, filters.qtfDegrees)
+ }
+
+ /**
+ * Pressing a direction switches the QTF filter on.
+ *
+ * Verified against the JavaFX toggle group rather than assumed: its
+ * selectedToggleProperty listener does
+ * `chatMemberTableFilterQtfEnableChkbx.setSelected(true)` whenever a direction is
+ * selected, and `false` when the selection is cleared
+ * (`Kst4ContestApplication.java:8758-8767`). An earlier version of this test claimed
+ * the opposite and had to be taken on faith; it is pinned to the source now.
+ */
+ @Test
+ fun `picking a bearing switches the QTF filter on, as the toggle group did`() {
+ val filters = StationFilterState()
+
+ filters.toggleBearing(Bearing.E)
+
+ assertEquals(setOf(StationFilter.QTF), filters.activeFilters)
+ assertEquals(90.0, filters.qtfDegrees)
+ }
+
+ /** Pressing the selected direction again clears it, and the filter with it. */
+ @Test
+ fun `pressing the same bearing again switches the QTF filter off`() {
+ val filters = StationFilterState()
+ filters.toggleBearing(Bearing.E)
+
+ filters.toggleBearing(Bearing.E)
+
+ assertNull(filters.bearing)
+ assertTrue(filters.activeFilters.isEmpty())
+ }
+
+ @Test
+ fun `the QTF filter can also be switched on without a direction`() {
+ val filters = StationFilterState()
+
+ filters.qtfEnabled = true
+
+ assertEquals(setOf(StationFilter.QTF), filters.activeFilters)
+ }
+
+ @Test
+ fun `the maximum QRB filter needs its own switch`() {
+ val filters = StationFilterState()
+
+ filters.maxQrbKm = 300.0
+ assertTrue(filters.activeFilters.isEmpty(), "a distance alone filters nothing")
+
+ filters.maxQrbEnabled = true
+ assertEquals(setOf(StationFilter.MAX_QRB), filters.activeFilters)
+ }
+
+ @Test
+ fun `a search text filters while it is there and stops when it is cleared`() {
+ val filters = StationFilterState()
+
+ filters.searchText = "DN9"
+ assertEquals(setOf(StationFilter.SEARCH_TEXT), filters.activeFilters)
+
+ filters.searchText = " "
+ assertTrue(filters.activeFilters.isEmpty(),
+ "blank is not a search; leaving it would hide every station")
+ }
+
+ @Test
+ fun `switching a filter off removes it again`() {
+ val filters = StationFilterState()
+ filters.hideInactive = true
+ filters.onlyNewGrids = true
+
+ filters.hideInactive = false
+
+ assertEquals(setOf(StationFilter.ONLY_NEW_GRIDS), filters.activeFilters)
+ assertFalse(filters.hideInactive)
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/StatusIndicatorStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/StatusIndicatorStateTest.kt
new file mode 100644
index 00000000..8391b22f
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/StatusIndicatorStateTest.kt
@@ -0,0 +1,414 @@
+package kst4contest.view.compose
+
+import kst4contest.controller.On4KstConnectionState
+import kst4contest.model.ThreadStateMessage
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertNotNull
+import androidx.compose.runtime.snapshots.Snapshot
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Assertions.assertSame
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+/**
+ * The status bar tells the operator whether the link is alive and reminds them of a
+ * sked. Both are read at a glance mid contest, so the mapping from state to badge is
+ * pinned here rather than left to the eye.
+ */
+class StatusIndicatorStateTest {
+
+ // ---- the connection badge ----------------------------------------------
+
+ @Test
+ fun `online shows the plain badge`() {
+ assertEquals(ConnectionBadge.ONLINE, ConnectionIndicator.badgeFor(On4KstConnectionState.ONLINE))
+ assertEquals("LINK", ConnectionBadge.ONLINE.label)
+ }
+
+ @Test
+ fun `every step of the handshake counts as busy`() {
+ listOf(
+ On4KstConnectionState.CONNECTING,
+ On4KstConnectionState.WAITING_FOR_LOGIN_PROMPT,
+ On4KstConnectionState.AUTHENTICATING,
+ On4KstConnectionState.SYNCING_MAIN_CHAT,
+ On4KstConnectionState.SYNCING_SECOND_CHAT,
+ On4KstConnectionState.STOPPING,
+ ).forEach {
+ assertEquals(ConnectionBadge.BUSY, ConnectionIndicator.badgeFor(it), "$it")
+ }
+ assertEquals("LINK…", ConnectionBadge.BUSY.label)
+ }
+
+ /**
+ * A scheduled reconnect is not a working link. It reads as offline on purpose: the
+ * operator must see that nothing is being sent right now.
+ */
+ @Test
+ fun `waiting for a reconnect reads as offline`() {
+ assertEquals(ConnectionBadge.OFFLINE, ConnectionIndicator.badgeFor(On4KstConnectionState.RECONNECT_WAIT))
+ assertEquals(ConnectionBadge.OFFLINE, ConnectionIndicator.badgeFor(On4KstConnectionState.DISCONNECTED))
+ assertEquals("LINK!", ConnectionBadge.OFFLINE.label)
+ }
+
+ /** Before the first callback there is no state, and no state is not a link. */
+ @Test
+ fun `no state at all reads as offline`() {
+ assertEquals(ConnectionBadge.OFFLINE, ConnectionIndicator.badgeFor(null))
+ }
+
+ @Test
+ fun `no connection state is left unmapped`() {
+ On4KstConnectionState.entries.forEach {
+ assertNotNull(ConnectionIndicator.badgeFor(it), "$it has no badge")
+ }
+ }
+
+ @Test
+ fun `the tooltip names the state and the detail`() {
+ assertEquals(
+ "ON4KST link: ONLINE\nLogged in as DN9APW",
+ ConnectionIndicator.tooltipFor(On4KstConnectionState.ONLINE, "Logged in as DN9APW"),
+ )
+ }
+
+ @Test
+ fun `a missing detail falls back to the state name`() {
+ assertEquals(
+ "ON4KST link: DISCONNECTED\nDISCONNECTED",
+ ConnectionIndicator.tooltipFor(On4KstConnectionState.DISCONNECTED, " "),
+ )
+ assertEquals(
+ "ON4KST link: DISCONNECTED\nDISCONNECTED",
+ ConnectionIndicator.tooltipFor(On4KstConnectionState.DISCONNECTED, null),
+ )
+ }
+
+ // ---- the macOS menu title ---------------------------------------------
+
+ /**
+ * The menu title splits finer than the badge: shutting down and reconnecting each
+ * get their own wording, because in the menu bar there is room to say it.
+ */
+ @Test
+ fun `the macOS title distinguishes what the badge merges`() {
+ assertEquals("🟢 LINK: Connected", ConnectionIndicator.macOsMenuTitle(On4KstConnectionState.ONLINE))
+ assertEquals("🟡 LINK: Connecting…", ConnectionIndicator.macOsMenuTitle(On4KstConnectionState.CONNECTING))
+ assertEquals("🟡 LINK: Disconnecting…", ConnectionIndicator.macOsMenuTitle(On4KstConnectionState.STOPPING))
+ assertEquals("🔴 LINK: Reconnecting…", ConnectionIndicator.macOsMenuTitle(On4KstConnectionState.RECONNECT_WAIT))
+ assertEquals("🔴 LINK: Disconnected", ConnectionIndicator.macOsMenuTitle(On4KstConnectionState.DISCONNECTED))
+ assertEquals("🔴 LINK: Disconnected", ConnectionIndicator.macOsMenuTitle(null))
+ }
+
+ /**
+ * The detail line under the macOS menu title. Same text the tooltip's second line
+ * carries, because it is the same information reaching the operator by the other route.
+ */
+ @Test
+ fun `the macOS menu carries the detail as its own line`() {
+ assertEquals(
+ "Logged in as DN9APW",
+ ConnectionIndicator.detailFor(On4KstConnectionState.ONLINE, "Logged in as DN9APW"),
+ )
+ }
+
+ @Test
+ fun `a missing macOS detail falls back to the state name`() {
+ assertEquals(
+ "DISCONNECTED",
+ ConnectionIndicator.detailFor(On4KstConnectionState.DISCONNECTED, " "),
+ )
+ assertEquals("DISCONNECTED", ConnectionIndicator.detailFor(null, null))
+ }
+
+ @Test
+ fun `no connection state is left without a macOS title`() {
+ On4KstConnectionState.entries.forEach {
+ assertTrue(
+ ConnectionIndicator.macOsMenuTitle(it).contains("LINK"),
+ "$it has no macOS title",
+ )
+ }
+ }
+
+ // ---- the blinking notices ---------------------------------------------
+
+ @Test
+ fun `a notice starts hidden`() {
+ val notice = BlinkingNotice()
+ assertFalse(notice.visible)
+ assertEquals("", notice.text)
+ }
+
+ @Test
+ fun `showing a notice reveals it with its text`() {
+ val notice = BlinkingNotice()
+ notice.show("REMINDER: DL0ABC T-5m")
+ assertTrue(notice.visible)
+ assertEquals("REMINDER: DL0ABC T-5m", notice.text)
+ assertEquals("REMINDER: DL0ABC T-5m", notice.tooltip)
+ }
+
+ /**
+ * The badge sits in one row with the menu, so a long text is cut. The full text
+ * stays in the tooltip — nothing is lost, it is only not shouted.
+ */
+ @Test
+ fun `a long text is cut for the badge but kept in the tooltip`() {
+ val long = "REMINDER: DL0ABC/P T-10m on 1296 MHz, please turn the antenna"
+ val notice = BlinkingNotice()
+ notice.show(long)
+ assertEquals(38, notice.text.length)
+ assertTrue(notice.text.endsWith("..."))
+ assertEquals(long.take(35) + "...", notice.text)
+ assertEquals(long, notice.tooltip)
+ }
+
+ @Test
+ fun `a text that just fits is left alone`() {
+ val exactly38 = "x".repeat(38)
+ val notice = BlinkingNotice()
+ notice.show(exactly38)
+ assertEquals(exactly38, notice.text)
+ }
+
+ @Test
+ fun `a separate tooltip survives the cut`() {
+ val notice = BlinkingNotice()
+ notice.show("BAND+ DL0ABC", tooltip = "DL0ABC is still missing 23cm and 13cm")
+ assertEquals("BAND+ DL0ABC", notice.text)
+ assertEquals("DL0ABC is still missing 23cm and 13cm", notice.tooltip)
+ }
+
+ @Test
+ fun `hiding a notice takes it out of the bar`() {
+ val notice = BlinkingNotice()
+ notice.show("REMINDER: DL0ABC T-5m")
+ notice.hide()
+ assertFalse(notice.visible)
+ }
+
+ /**
+ * A second reminder while the first is still blinking must blink again, not sit
+ * there statically. The revision is what the blink restarts on.
+ */
+ @Test
+ fun `a second notice restarts the blink`() {
+ val notice = BlinkingNotice()
+ notice.show("first")
+ val afterFirst = notice.revision
+ notice.show("second")
+ assertTrue(notice.revision > afterFirst)
+ assertEquals("second", notice.text)
+ }
+
+ /**
+ * The blink runs in the view, so it is cancelled if the bar leaves the composition —
+ * a splitter dragged shut, a profile switch. Without a generation-aware hide the
+ * notice would stay visible for the rest of the session: a magenta reminder stuck in
+ * the status bar for a sked that has long passed.
+ */
+ @Test
+ fun `a notice can be hidden by the generation that showed it`() {
+ val notice = BlinkingNotice()
+ notice.show("REMINDER: DL0ABC T-5m")
+ val generation = notice.revision
+
+ notice.hide(generation)
+
+ assertFalse(notice.visible)
+ }
+
+ /**
+ * And a stale blink must not hide the notice that replaced it. When a second reminder
+ * arrives while the first is still blinking, the first blink's cleanup runs after the
+ * second has already been shown.
+ */
+ @Test
+ fun `an older generation cannot hide a newer notice`() {
+ val notice = BlinkingNotice()
+ notice.show("first")
+ val firstGeneration = notice.revision
+
+ notice.show("second")
+ notice.hide(firstGeneration)
+
+ assertTrue(notice.visible, "the stale blink hid the reminder that replaced it")
+ assertEquals("second", notice.text)
+ }
+
+ @Test
+ fun `the blink lasts as long as it did in JavaFX`() {
+ assertEquals(24, BlinkingNotice.BLINK_CYCLES)
+ assertEquals(500L, BlinkingNotice.BLINK_CYCLE_MILLIS)
+ assertEquals(12_000L, BlinkingNotice.blinkDurationMillis)
+ }
+
+ // ---- what triggers a notice -------------------------------------------
+
+ @Test
+ fun `the reminder text names the call and the minutes left`() {
+ assertEquals("REMINDER: DL0ABC T-5m", StatusIndicators.reminderText("DL0ABC", 5))
+ }
+
+ @Test
+ fun `a band upgrade is recognized by the key`() {
+ val notice = StatusIndicators.bandUpgradeNoticeFor(
+ key = "BandUpgradeWatcher",
+ message = threadState(nick = "watcher", description = "BAND+ DL0ABC", info = "23cm missing"),
+ )
+ assertNotNull(notice)
+ assertEquals("BAND+ DL0ABC", notice!!.first)
+ assertEquals("23cm missing", notice.second)
+ }
+
+ @Test
+ fun `a band upgrade is recognized by the thread nickname`() {
+ assertNotNull(
+ StatusIndicators.bandUpgradeNoticeFor(
+ key = "worker-7",
+ message = threadState(nick = "bandUpgrade", description = "BAND+", info = "x"),
+ ),
+ )
+ }
+
+ @Test
+ fun `any other thread message is not a band upgrade`() {
+ assertNull(
+ StatusIndicators.bandUpgradeNoticeFor(
+ key = "ClusterReader",
+ message = threadState(nick = "cluster", description = "12 spots", info = "running"),
+ ),
+ )
+ assertNull(StatusIndicators.bandUpgradeNoticeFor(key = "bandupgrade", message = null))
+ assertNull(StatusIndicators.bandUpgradeNoticeFor(key = null, message = null))
+ }
+
+ /**
+ * ThreadStateMessage never hands out a blank description — it falls back to "on"
+ * or "FAILED" itself. So the notice shows that, and the tooltip, which has no such
+ * fallback, borrows the button text rather than appearing empty.
+ */
+ @Test
+ fun `a band upgrade without its own wording borrows what it has`() {
+ val notice = StatusIndicators.bandUpgradeNoticeFor(
+ key = "bandupgrade",
+ message = ThreadStateMessage("x", true, " ", false),
+ )
+ assertNotNull(notice)
+ assertEquals("on", notice!!.first)
+ assertEquals("on", notice.second)
+ }
+
+ // ---- the background thread buttons ------------------------------------
+
+ @Test
+ fun `a thread reports itself into the bar once`() {
+ val buttons = ThreadStatusButtons()
+ buttons.update("ClusterReader", threadState(nick = "cluster", description = "12 spots", info = "running"))
+ buttons.update("ClusterReader", threadState(nick = "cluster", description = "13 spots", info = "still running"))
+
+ assertEquals(1, buttons.buttons.size)
+ assertEquals("ClusterReader: 13 spots", buttons.buttons.single().label)
+ assertEquals("still running", buttons.buttons.single().tooltip)
+ }
+
+ /** The bar is read left to right; a thread keeps the place it first took. */
+ @Test
+ fun `threads keep the order they first reported in`() {
+ val buttons = ThreadStatusButtons()
+ buttons.update("Cluster", threadState(nick = "c", description = "a", info = "a"))
+ buttons.update("Sked", threadState(nick = "s", description = "b", info = "b"))
+ buttons.update("Cluster", threadState(nick = "c", description = "c", info = "c"))
+
+ assertEquals(listOf("Cluster", "Sked"), buttons.buttons.map { it.sourceName })
+ }
+
+ /**
+ * The flash is the whole point of these buttons: it shows the thread is alive.
+ * It is on when the message lands and the view times it out again.
+ */
+ @Test
+ fun `a fresh message flashes the button`() {
+ val buttons = ThreadStatusButtons()
+ buttons.update("Cluster", threadState(nick = "c", description = "a", info = "a"))
+ val button = buttons.buttons.single()
+ assertTrue(button.highlighted)
+
+ button.clearHighlight()
+ assertFalse(button.highlighted)
+
+ buttons.update("Cluster", threadState(nick = "c", description = "b", info = "b"))
+ assertTrue(button.highlighted)
+ }
+
+ @Test
+ fun `the flash lasts as long as it did in JavaFX`() {
+ assertEquals(200L, ThreadStatusButtons.HIGHLIGHT_MILLIS)
+ }
+
+ // ---- what a draw subscribes to ----------------------------------------
+
+ /**
+ * The bar is drawn from these values, so reading them has to register as a read.
+ * A plain field would draw once and then quietly go stale — the failure mode that
+ * cost this migration eleven defects, none of which any assertion could see.
+ */
+ @Test
+ fun `drawing a notice subscribes to what can change`() {
+ val notice = BlinkingNotice()
+ notice.show("REMINDER: DL0ABC T-5m")
+
+ val read = mutableSetOf()
+ Snapshot.observe(readObserver = { read += it.toString() }) {
+ notice.visible
+ notice.text
+ notice.tooltip
+ notice.revision
+ }
+
+ assertEquals(4, read.size, "a notice value was read without subscribing: $read")
+ }
+
+ @Test
+ fun `drawing the thread buttons subscribes to the list and the labels`() {
+ val buttons = ThreadStatusButtons()
+ buttons.update("Cluster", threadState(nick = "c", description = "a", info = "a"))
+
+ val read = mutableSetOf()
+ Snapshot.observe(readObserver = { read += it.toString() }) {
+ buttons.buttons.forEach {
+ it.label
+ it.tooltip
+ it.highlighted
+ }
+ }
+
+ // the list itself plus three values of the single button
+ assertEquals(4, read.size, "a button value was read without subscribing: $read")
+ }
+
+ /**
+ * A thread that reports again does not change the list, only its own button. If the
+ * label were not state, the bar would freeze on the first message a worker sent.
+ */
+ @Test
+ fun `a later message changes the button without touching the list`() {
+ val buttons = ThreadStatusButtons()
+ buttons.update("Cluster", threadState(nick = "c", description = "a", info = "a"))
+ val listBefore = buttons.buttons
+ val button = buttons.buttons.single()
+
+ buttons.update("Cluster", threadState(nick = "c", description = "b", info = "b"))
+
+ assertSame(listBefore, buttons.buttons)
+ assertSame(button, buttons.buttons.single())
+ assertEquals("Cluster: b", button.label)
+ }
+
+ private fun threadState(nick: String, description: String, info: String): ThreadStateMessage =
+ ThreadStateMessage(nick, true, info, false).apply {
+ setRunningInformationTextDescription(description)
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/TimelineStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/TimelineStateTest.kt
new file mode 100644
index 00000000..7230a171
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/TimelineStateTest.kt
@@ -0,0 +1,316 @@
+package kst4contest.view.compose
+
+import androidx.compose.runtime.snapshots.Snapshot
+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
+
+/**
+ * The timeline is the one view in this client that is read as a picture rather than as
+ * text, so its geometry and its fading rules are pinned here. Everything in this file
+ * is the original JavaFX behaviour; where it looks surprising, it is surprising there
+ * too and the test says so.
+ */
+class TimelineStateTest {
+
+ private val width = 400f
+
+ // ---- the potential colours --------------------------------------------
+
+ @Test
+ fun `the potential steps break where they always did`() {
+ assertEquals(PotentialLevel.HIGHEST, PotentialLevel.forPercent(100))
+ assertEquals(PotentialLevel.HIGHEST, PotentialLevel.forPercent(95))
+ assertEquals(PotentialLevel.HIGH, PotentialLevel.forPercent(94))
+ assertEquals(PotentialLevel.HIGH, PotentialLevel.forPercent(75))
+ assertEquals(PotentialLevel.MEDIUM, PotentialLevel.forPercent(74))
+ assertEquals(PotentialLevel.MEDIUM, PotentialLevel.forPercent(50))
+ assertEquals(PotentialLevel.LOW, PotentialLevel.forPercent(49))
+ assertEquals(PotentialLevel.LOW, PotentialLevel.forPercent(0))
+ }
+
+ /** A missing potential arrives as a negative number and must not fall through. */
+ @Test
+ fun `an unknown potential is the lowest step`() {
+ assertEquals(PotentialLevel.LOW, PotentialLevel.forPercent(-1))
+ }
+
+ // ---- the geometry ------------------------------------------------------
+
+ /**
+ * Counter-intuitive and deliberate: the strip is not a progress bar. Time grows to
+ * the right, so what is about to happen sits at the LEFT edge.
+ */
+ @Test
+ fun `now is on the left and the far future on the right`() {
+ val imminent = TimelineGeometry.xFor(0, width, TimelineGeometry.SKED_MARGIN)!!
+ val distant = TimelineGeometry.xFor(TimelineGeometry.PREVIEW_MILLIS, width, TimelineGeometry.SKED_MARGIN)!!
+ assertTrue(imminent < distant, "time must grow to the right")
+ }
+
+ @Test
+ fun `the middle of the window is the middle of the strip`() {
+ assertEquals(
+ 200f,
+ TimelineGeometry.xFor(TimelineGeometry.PREVIEW_MILLIS / 2, width, TimelineGeometry.SKED_MARGIN)!!,
+ 0.01f,
+ )
+ }
+
+ /** Both edges are held off, or half a marker would hang outside the strip. */
+ @Test
+ fun `a marker never reaches the edge`() {
+ assertEquals(
+ TimelineGeometry.SKED_MARGIN,
+ TimelineGeometry.xFor(0, width, TimelineGeometry.SKED_MARGIN)!!,
+ )
+ assertEquals(
+ width - TimelineGeometry.SKED_MARGIN,
+ TimelineGeometry.xFor(TimelineGeometry.PREVIEW_MILLIS, width, TimelineGeometry.SKED_MARGIN)!!,
+ )
+ }
+
+ /**
+ * Candidates are held further from the edge than skeds. Their labels are wider, so
+ * the original picked two different margins — the difference is real and kept.
+ */
+ @Test
+ fun `candidates keep further from the edge than skeds`() {
+ assertTrue(TimelineGeometry.CANDIDATE_MARGIN > TimelineGeometry.SKED_MARGIN)
+ assertEquals(
+ TimelineGeometry.CANDIDATE_MARGIN,
+ TimelineGeometry.xFor(0, width, TimelineGeometry.CANDIDATE_MARGIN)!!,
+ )
+ }
+
+ @Test
+ fun `an event outside the window is not placed at all`() {
+ assertNull(TimelineGeometry.xFor(-1, width, TimelineGeometry.SKED_MARGIN))
+ assertNull(
+ TimelineGeometry.xFor(TimelineGeometry.PREVIEW_MILLIS + 1, width, TimelineGeometry.SKED_MARGIN),
+ )
+ }
+
+ /**
+ * A pane dragged narrower than two margins used to throw: coerceIn(30f, 10f) is an
+ * empty range, and Kotlin refuses it rather than picking a side. The operator runs a
+ * tiling compositor, so a strip narrower than 60 px is a Tuesday, not a corner case.
+ */
+ @Test
+ fun `a strip narrower than its own margins still places a marker`() {
+ val x = TimelineGeometry.xFor(
+ timeUntilMs = 5 * 60 * 1000,
+ width = 40f,
+ margin = TimelineGeometry.CANDIDATE_MARGIN,
+ )
+ assertEquals(20f, x!!, 0.01f, "a narrow strip should centre the marker")
+ }
+
+ @Test
+ fun `a strip of no width at all does not throw`() {
+ val x = TimelineGeometry.xFor(0, width = 0f, margin = TimelineGeometry.SKED_MARGIN)
+ assertEquals(0f, x!!, 0.01f)
+ }
+
+ @Test
+ fun `the window looks thirty minutes ahead`() {
+ assertEquals(30L * 60L * 1000L, TimelineGeometry.PREVIEW_MILLIS)
+ }
+
+ // ---- the lanes ---------------------------------------------------------
+
+ @Test
+ fun `the lanes stack downwards in even steps`() {
+ assertEquals(2f, TimelineGeometry.laneY(0))
+ assertEquals(22f, TimelineGeometry.laneY(1))
+ }
+
+ /**
+ * The candidate lanes stay above the axis and the skeds below it. This is the one
+ * place where this port does not copy the original: there the lanes were laid out
+ * for a 40 px strip that the call site had already made 80 px tall, so the candidate
+ * labels, the sked diamonds and the sked labels all landed on the same pixels while
+ * the lower half stayed empty.
+ */
+ @Test
+ fun `nothing is drawn across the axis or off the strip`() {
+ val laneBottom = TimelineGeometry.laneY(TimelineGeometry.LANE_COUNT - 1) + TimelineGeometry.LANE_HEIGHT
+ assertTrue(
+ laneBottom <= TimelineGeometry.AXIS_Y,
+ "candidate lanes reach the axis: $laneBottom vs ${TimelineGeometry.AXIS_Y}",
+ )
+ assertTrue(
+ TimelineGeometry.SKED_Y > TimelineGeometry.AXIS_Y,
+ "skeds must sit below the axis",
+ )
+ assertTrue(
+ TimelineGeometry.SKED_Y + TimelineGeometry.LANE_HEIGHT <= TimelineGeometry.HEIGHT,
+ "the sked lane runs off the bottom",
+ )
+ }
+
+ /** A third lane would land on the axis, so it shares the last one instead. */
+ @Test
+ fun `a lane beyond the available ones folds onto the last`() {
+ assertEquals(TimelineGeometry.laneY(1), TimelineGeometry.laneY(2))
+ assertEquals(TimelineGeometry.laneY(1), TimelineGeometry.laneY(7))
+ assertEquals(TimelineGeometry.laneY(0), TimelineGeometry.laneY(-1))
+ }
+
+ // ---- the labels --------------------------------------------------------
+
+ @Test
+ fun `a label sits to the right of its marker`() {
+ assertEquals(54f, TimelineGeometry.labelStartX(markerX = 50f, labelWidth = 60f, stripWidth = width, gap = 4f))
+ }
+
+ /** Near the right edge it flips instead of running off — half an hour away there is always room. */
+ @Test
+ fun `a label near the right edge flips to the left`() {
+ assertEquals(
+ 336f,
+ TimelineGeometry.labelStartX(markerX = 400f, labelWidth = 60f, stripWidth = width, gap = 4f),
+ )
+ }
+
+ @Test
+ fun `a label wider than the strip still starts on it`() {
+ assertEquals(
+ 0f,
+ TimelineGeometry.labelStartX(markerX = 10f, labelWidth = 900f, stripWidth = width, gap = 4f),
+ )
+ }
+
+ // ---- the antenna fading -----------------------------------------------
+
+ @Test
+ fun `a target dead ahead glows and grows`() {
+ val e = AntennaEmphasis.forAzimuth(currentAzimuth = 90.0, targetAzimuth = 90.0, beamWidthDeg = 50.0)
+ assertEquals(1f, e.iconAlpha)
+ assertTrue(e.glowing)
+ assertEquals(1.10f, e.scale)
+ }
+
+ @Test
+ fun `the glow reaches exactly half a beam width`() {
+ assertTrue(AntennaEmphasis.forAzimuth(0.0, 25.0, 50.0).glowing)
+ assertFalse(AntennaEmphasis.forAzimuth(0.0, 25.1, 50.0).glowing)
+ }
+
+ /**
+ * Between half a beam and a full beam the marker stays bright without the glow. The
+ * station is still workable; it is just not centred.
+ */
+ @Test
+ fun `just off centre stays bright without the glow`() {
+ val e = AntennaEmphasis.forAzimuth(0.0, 40.0, 50.0)
+ assertEquals(1f, e.iconAlpha)
+ assertFalse(e.glowing)
+ assertEquals(1f, e.scale)
+ }
+
+ @Test
+ fun `the full beam width is still bright`() {
+ assertEquals(1f, AntennaEmphasis.forAzimuth(0.0, 50.0, 50.0).iconAlpha)
+ }
+
+ /**
+ * Beyond the beam the marker fades but does not vanish. An operator must be able to
+ * see a chance in a direction they are not pointing, or turning the rotator would
+ * never occur to them.
+ */
+ @Test
+ fun `a target outside the beam fades to a ghost but stays visible`() {
+ val e = AntennaEmphasis.forAzimuth(0.0, 120.0, 50.0)
+ assertEquals(0.30f, e.iconAlpha)
+ assertFalse(e.glowing)
+ assertTrue(e.iconAlpha > 0f, "a ghost is still a marker")
+ }
+
+ /** North is the case that breaks a naive subtraction: 350° and 10° are 20° apart. */
+ @Test
+ fun `the beam reaches across north`() {
+ assertTrue(AntennaEmphasis.forAzimuth(350.0, 10.0, 50.0).glowing)
+ assertTrue(AntennaEmphasis.forAzimuth(10.0, 350.0, 50.0).glowing)
+ }
+
+ @Test
+ fun `an unknown azimuth is left readable`() {
+ assertEquals(AntennaEmphasis.PLAIN, AntennaEmphasis.forAzimuth(0.0, -1.0, 50.0))
+ assertEquals(AntennaEmphasis.PLAIN, AntennaEmphasis.forAzimuth(0.0, Double.NaN, 50.0))
+ }
+
+ // ---- the state --------------------------------------------------------
+
+ @Test
+ fun `the default beam width is the one the original assumed`() {
+ assertEquals(50.0, TimelineState().beamWidthDeg)
+ }
+
+ @Test
+ fun `a nonsense beam width is refused rather than drawn`() {
+ val state = TimelineState()
+ state.setBeamWidth(0.0)
+ assertEquals(50.0, state.beamWidthDeg)
+ state.setBeamWidth(-30.0)
+ assertEquals(50.0, state.beamWidthDeg)
+ state.setBeamWidth(Double.NaN)
+ assertEquals(50.0, state.beamWidthDeg)
+ state.setBeamWidth(12.5)
+ assertEquals(12.5, state.beamWidthDeg)
+ }
+
+ @Test
+ fun `the antenna direction decides the emphasis`() {
+ val state = TimelineState()
+ state.antennaAzimuth = 200.0
+ assertTrue(state.emphasisFor(200.0).glowing)
+ assertEquals(0.30f, state.emphasisFor(20.0).iconAlpha)
+ }
+
+ // ---- what a draw subscribes to ----------------------------------------
+
+ /**
+ * The strip is redrawn on the score pulse, on every rotator step and on every
+ * resize. Each of those has to reach the screen on its own, so reading any of it
+ * must register as a read.
+ */
+ @Test
+ fun `drawing the timeline subscribes to what can change`() {
+ val state = TimelineState()
+ val read = mutableSetOf()
+
+ Snapshot.observe(readObserver = { read += it.toString() }) {
+ state.skeds.size
+ state.candidates.size
+ state.antennaAzimuth
+ state.beamWidthDeg
+ }
+
+ assertEquals(4, read.size, "a timeline value was read without subscribing: $read")
+ }
+
+ @Test
+ fun `replacing the candidates replaces them rather than appending`() {
+ val state = TimelineState()
+ state.replaceCandidates(listOf(candidate("DL0ABC", lane = 0)))
+ state.replaceCandidates(listOf(candidate("DL0XYZ", lane = 0), candidate("DL0QRS", lane = 1)))
+
+ assertEquals(listOf("DL0XYZ", "DL0QRS"), state.candidates.map { it.displayCallSign })
+ }
+
+ private fun candidate(call: String, lane: Int) = TimelineCandidate(
+ callSignRaw = call,
+ displayCallSign = call,
+ preferredChatCategory = null,
+ timeUntilMs = 60_000,
+ minuteBucket = 1,
+ laneIndex = lane,
+ targetAzimuth = 90.0,
+ score = 1.0,
+ opportunityPotentialPercent = 80,
+ tooltipText = null,
+ )
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/TopPriorityStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/TopPriorityStateTest.kt
new file mode 100644
index 00000000..4debce3b
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/TopPriorityStateTest.kt
@@ -0,0 +1,122 @@
+package kst4contest.view.compose
+
+import kst4contest.model.ChatMember
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertSame
+import org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * The two priority buttons beside the station list.
+ *
+ * They are a shortcut to the station the score says to work next, so what they name
+ * and what they do when pressed is the whole of their value.
+ *
+ * `refresh()` is what fills them, and the score service is what calls it
+ * (`Kst4ContestApplication` wires it to `topCandidates()` and calls it once at
+ * startup). Reading the ranking on demand instead would never reach the screen: the
+ * ranking is not Compose state, so a button derived from it could not ask for a
+ * redraw when the score changed.
+ */
+class TopPriorityStateTest {
+
+ private fun member(call: String) = ChatMember().apply { callSign = call }
+
+ @Test
+ fun `the buttons name the highest scoring stations and their score`() {
+ val pa6i = member("PA6I")
+ val dk7se = member("DK7SE")
+ val state = TopPriorityState(
+ topStations = { listOf(pa6i to 968.0, dk7se to 895.0) },
+ onSelect = { },
+ )
+
+ state.refresh()
+
+ assertEquals(listOf("1 PA6I 968", "2 DK7SE 895"), state.entries.map { it.label })
+ }
+
+ @Test
+ fun `only the first two are offered`() {
+ val state = TopPriorityState(
+ topStations = { (1..5).map { member("DL${it}ABC") to it.toDouble() } },
+ onSelect = { },
+ )
+
+ state.refresh()
+
+ assertEquals(2, state.entries.size, "the rest are behind the more button")
+ }
+
+ @Test
+ fun `fewer than two stations gives fewer buttons`() {
+ val state = TopPriorityState(topStations = { listOf(member("DN9APW") to 10.0) }, onSelect = { })
+
+ state.refresh()
+
+ assertEquals(1, state.entries.size)
+ }
+
+ @Test
+ fun `with no stations there is nothing to press`() {
+ val state = TopPriorityState(topStations = { emptyList() }, onSelect = { })
+
+ state.refresh()
+
+ assertTrue(state.entries.isEmpty())
+ }
+
+ @Test
+ fun `pressing one selects that station`() {
+ val pa6i = member("PA6I")
+ var selected: ChatMember? = null
+ val state = TopPriorityState(topStations = { listOf(pa6i to 968.0) }, onSelect = { selected = it })
+ state.refresh()
+
+ state.entries.first().select()
+
+ assertSame(pa6i, selected)
+ }
+
+ @Test
+ fun `a station without a callsign is skipped rather than shown blank`() {
+ val state = TopPriorityState(
+ topStations = { listOf(ChatMember() to 500.0, member("DN9APW") to 400.0) },
+ onSelect = { },
+ )
+
+ state.refresh()
+
+ assertEquals(listOf("1 DN9APW 400"), state.entries.map { it.label })
+ }
+
+ /**
+ * Nothing is shown until the score service has spoken.
+ *
+ * Deliberate: the ranking lives outside Compose, so the buttons are pushed from the
+ * score service rather than pulled during composition. An earlier version derived
+ * them on read, which looked simpler and could never have updated the screen.
+ */
+ @Test
+ fun `the buttons stay empty until the score service fills them`() {
+ val state = TopPriorityState(topStations = { listOf(member("PA6I") to 968.0) }, onSelect = { })
+
+ assertTrue(state.entries.isEmpty(), "nothing should appear before the first refresh")
+
+ state.refresh()
+
+ assertEquals(listOf("1 PA6I 968"), state.entries.map { it.label })
+ }
+
+ @Test
+ fun `a later refresh replaces what the buttons said`() {
+ var ranking = listOf(member("PA6I") to 968.0)
+ val state = TopPriorityState(topStations = { ranking }, onSelect = { })
+ state.refresh()
+
+ ranking = listOf(member("DK7SE") to 1200.0)
+ state.refresh()
+
+ assertEquals(listOf("1 DK7SE 1200"), state.entries.map { it.label })
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/TypographyScalingTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/TypographyScalingTest.kt
new file mode 100644
index 00000000..7ba8e231
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/TypographyScalingTest.kt
@@ -0,0 +1,31 @@
+package kst4contest.view.compose
+
+import androidx.compose.material3.Typography
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Assertions.assertEquals
+
+class TypographyScalingTest {
+
+ @Test
+ fun `the base size replaces the body size and the rest keeps its ratio`() {
+ val original = Typography()
+ val originalBody = original.bodyMedium.fontSize.value
+ val originalTitle = original.titleMedium.fontSize.value
+
+ val scaled = original.scaledTo(originalBody * 2f)
+
+ assertEquals(originalBody * 2f, scaled.bodyMedium.fontSize.value, 0.01f)
+ assertEquals(originalTitle * 2f, scaled.titleMedium.fontSize.value, 0.01f,
+ "every style scales by the same factor, so the design keeps its proportions")
+ }
+
+ @Test
+ fun `a base size of zero or less is ignored`() {
+ val original = Typography()
+
+ assertEquals(original.bodyMedium.fontSize.value,
+ original.scaledTo(0f).bodyMedium.fontSize.value, 0.01f)
+ assertEquals(original.bodyMedium.fontSize.value,
+ original.scaledTo(-5f).bodyMedium.fontSize.value, 0.01f)
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/UpdateWindowStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/UpdateWindowStateTest.kt
new file mode 100644
index 00000000..3d2d57d8
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/UpdateWindowStateTest.kt
@@ -0,0 +1,123 @@
+package kst4contest.view.compose
+
+import kst4contest.model.UpdateInformation
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+
+/**
+ * When the update window opens, and what it says.
+ *
+ * The decision matters in both directions: a window that opens without an update is a
+ * daily annoyance, and one that stays shut when an update exists defeats the whole
+ * mechanism.
+ */
+class UpdateWindowStateTest {
+
+ private fun semantic(version: String) = UpdateInformation().apply {
+ latestSemanticVersionOnServer = version
+ }
+
+ private fun numeric(number: Double) = UpdateInformation().apply {
+ latestVersionNumberOnServer = number
+ }
+
+ private fun state(information: UpdateInformation?) =
+ UpdateWindowState(information, currentVersion = "1.50.0", currentVersionNumber = 1.50)
+
+ @Test
+ fun `a newer semantic version on the server is an update`() {
+ assertTrue(state(semantic("1.51.0")).updateAvailable)
+ assertTrue(state(semantic("2.0.0")).updateAvailable)
+ }
+
+ @Test
+ fun `the same or an older semantic version is not`() {
+ assertFalse(state(semantic("1.50.0")).updateAvailable,
+ "the running version is not an update to itself")
+ assertFalse(state(semantic("1.49.9")).updateAvailable,
+ "a server rolled back must not push the operator downwards")
+ }
+
+ @Test
+ fun `without a semantic version the numeric comparison decides`() {
+ val older = numeric(1.49)
+ val newer = numeric(1.51)
+
+ assertFalse(older.hasSemanticVersion(), "this is the path the test is about")
+ assertFalse(state(older).updateAvailable)
+ assertTrue(state(newer).updateAvailable)
+ }
+
+ @Test
+ fun `absent update information is not an update`() {
+ // The check runs at startup. A download that failed must not keep the
+ // application from coming up, and must not claim an update either.
+ assertFalse(state(null).updateAvailable)
+ }
+
+ @Test
+ fun `the version lines carry the product name, not the directory name`() {
+ val state = state(semantic("1.51.0"))
+
+ assertEquals("KST4Contest 1.50.0", state.installedVersion)
+ assertEquals("KST4Contest 1.51.0", state.latestVersion)
+ }
+
+ @Test
+ fun `a change log row becomes a heading with its lines`() {
+ val information = semantic("1.51.0").apply {
+ changeLog = arrayListOf(
+ arrayOf("1.51.0", "Compose settings window", "Tabs sized to their titles"),
+ arrayOf("1.50.0", "Gradle build"),
+ )
+ }
+
+ val sections = state(information).changeLog
+
+ assertEquals(2, sections.size)
+ assertEquals("1.51.0", sections[0].title)
+ assertEquals(
+ listOf("Compose settings window", "Tabs sized to their titles"),
+ sections[0].entries,
+ )
+ assertEquals(listOf("Gradle build"), sections[1].entries)
+ }
+
+ @Test
+ fun `a heading without lines is kept`() {
+ val information = semantic("1.51.0").apply {
+ changeLog = arrayListOf(arrayOf("1.51.0"))
+ }
+
+ val sections = state(information).changeLog
+
+ assertEquals(1, sections.size,
+ "a version that brought no listed change is still worth showing as released")
+ assertTrue(sections[0].entries.isEmpty())
+ }
+
+ @Test
+ fun `known bugs read the same way as the change log`() {
+ val information = semantic("1.51.0").apply {
+ bugList = arrayListOf(arrayOf("Profile switch", "The confirmation opens behind"))
+ }
+
+ val sections = state(information).knownBugs
+
+ assertEquals("Profile switch", sections[0].title)
+ assertEquals(listOf("The confirmation opens behind"), sections[0].entries)
+ }
+
+ @Test
+ fun `an empty update information yields empty text rather than null`() {
+ val state = state(UpdateInformation())
+
+ assertEquals("", state.majorChanges)
+ assertEquals("", state.adminMessage)
+ assertEquals("", state.releasePageUrl)
+ assertTrue(state.changeLog.isEmpty())
+ assertTrue(state.knownBugs.isEmpty())
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/WindowShortcutsTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/WindowShortcutsTest.kt
new file mode 100644
index 00000000..c62488cd
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/WindowShortcutsTest.kt
@@ -0,0 +1,45 @@
+package kst4contest.view.compose
+
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Test
+
+/**
+ * Enter, Escape and Ctrl+1…Ctrl+0 belong to the whole window, not to the message field.
+ *
+ * JavaFX hung all three on the SCENE (7455-7474), and the tables even re-fired the send
+ * button from their own key filter. An operator who has just clicked a station, a message row
+ * or a button must still be able to press Enter and have the message go — and Ctrl+1…Ctrl+0
+ * is how snippets are used at all, since the operator's hands are on the keyboard.
+ */
+class WindowShortcutsTest {
+
+ @Test
+ fun `control and the digit keys map to the ten snippet slots`() {
+ assertEquals(0, WindowShortcuts.snippetIndexFor("1"))
+ assertEquals(1, WindowShortcuts.snippetIndexFor("2"))
+ assertEquals(8, WindowShortcuts.snippetIndexFor("9"))
+ }
+
+ /** Ctrl+0 is the tenth, not the first — the row of digits reads 1…9 then 0. */
+ @Test
+ fun `control zero is the tenth slot`() {
+ assertEquals(9, WindowShortcuts.snippetIndexFor("0"))
+ }
+
+ @Test
+ fun `any other key is not a snippet shortcut`() {
+ assertNull(WindowShortcuts.snippetIndexFor("a"))
+ assertNull(WindowShortcuts.snippetIndexFor(""))
+ assertNull(WindowShortcuts.snippetIndexFor("12"))
+ }
+
+ /** All ten slots are reachable and none is reachable twice. */
+ @Test
+ fun `the ten digits cover the ten slots exactly once`() {
+ val indices = listOf("1", "2", "3", "4", "5", "6", "7", "8", "9", "0")
+ .mapNotNull { WindowShortcuts.snippetIndexFor(it) }
+
+ assertEquals((0..9).toList(), indices)
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/ComposeStationMapDrawingTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/ComposeStationMapDrawingTest.kt
new file mode 100644
index 00000000..0dba96d0
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/ComposeStationMapDrawingTest.kt
@@ -0,0 +1,266 @@
+package kst4contest.view.compose.map
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.ui.ImageComposeScene
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.Density
+import kst4contest.view.map.MapCallsignRawSnapshot
+import kst4contest.view.map.TileFetcher
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+import java.awt.image.BufferedImage
+import java.io.ByteArrayInputStream
+import javax.imageio.ImageIO
+import kotlin.math.abs
+
+/**
+ * What the map actually puts on the canvas.
+ *
+ * Asserted on the rendered image, because that is the only place the answers live: the
+ * drawing code takes a viewport and paints, and no amount of reading it proves a dot
+ * came out 12 px wide. These are the acceptance criteria of the projection rewrite and
+ * of the grid planner, in the form the operator experiences them.
+ *
+ * Tiles never arrive here. The fetcher is asked asynchronously and the test renders one
+ * frame, so what is measured is the map's own drawing over its own background — which is
+ * also the case an operator meets without a network.
+ */
+class ComposeStationMapDrawingTest {
+
+ private val canvasWidth = 900
+ private val canvasHeight = 600
+
+ /** The default station-dot border, `#4da6ff` in the Leaflet stylesheet. */
+ private val dotBorder = 0x4DA6FF
+
+ /** The home marker and the antenna beam, `#ff4d4d`. */
+ private val homeColour = 0xFF4D4D
+
+ /** The Maidenhead label colour, `#63067a`. */
+ private val gridLabel = 0x63067A
+
+ private val darkMapBackground = 0x23282D
+
+ private fun station(call: String, locator: String, lat: Double, lon: Double) =
+ MapCallsignRawSnapshot(
+ call, call, locator, lat, lon, "", emptyMap(),
+ false, false, false, false, 0.0, 0.0, 0, 0L,
+ )
+
+ private fun render(
+ zoom: Float,
+ markers: List = listOf(station("DN9APW", "JO50JP", 51.0, 10.0)),
+ ownLocator6: String = "",
+ ): BufferedImage {
+ val scene = ImageComposeScene(canvasWidth, canvasHeight, Density(1f)) {
+ ComposeStationMap(
+ markers = markers,
+ tileFetcher = TileFetcher(),
+ mapState = MapState(zoom = zoom, centerLon = 10.0, centerLat = 51.0),
+ groupingEnabled = false,
+ selectedMarkerCallsign = null,
+ ownLocator6 = ownLocator6,
+ antennaAzimuthDeg = 90.0,
+ antennaBeamWidthDeg = 0.0,
+ maxQrbKm = 0.0,
+ onMarkerClicked = null,
+ darkMode = true,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+ try {
+ return ImageIO.read(ByteArrayInputStream(scene.render().encodeToData()!!.bytes))
+ } finally {
+ scene.close()
+ }
+ }
+
+ private fun countColour(image: BufferedImage, rgb: Int): Int {
+ var count = 0
+ for (x in 0 until image.width) {
+ for (y in 0 until image.height) {
+ if ((image.getRGB(x, y) and 0xFFFFFF) == rgb) count++
+ }
+ }
+ return count
+ }
+
+ /** The widest horizontal run of a colour, which for a ring is its outer diameter. */
+ private fun widestRun(image: BufferedImage, rgb: Int): Int {
+ var widest = 0
+ for (y in 0 until image.height) {
+ var first = -1
+ var last = -1
+ for (x in 0 until image.width) {
+ if ((image.getRGB(x, y) and 0xFFFFFF) == rgb) {
+ if (first < 0) first = x
+ last = x
+ }
+ }
+ if (first >= 0) widest = maxOf(widest, last - first + 1)
+ }
+ return widest
+ }
+
+ /** The mean position of a colour, or null when it is absent. */
+ private fun centroidOf(image: BufferedImage, rgb: Int): Pair? {
+ var sumX = 0L
+ var sumY = 0L
+ var count = 0
+ for (x in 0 until image.width) {
+ for (y in 0 until image.height) {
+ if ((image.getRGB(x, y) and 0xFFFFFF) == rgb) {
+ sumX += x
+ sumY += y
+ count++
+ }
+ }
+ }
+ return if (count == 0) null else Pair(sumX.toDouble() / count, sumY.toDouble() / count)
+ }
+
+ /**
+ * The invariant the projection rewrite exists to make obvious: a size written in the
+ * drawing code is the size that reaches the canvas. The previous version reached the
+ * same geometry by dividing each size by the zoom fraction inside a scaled canvas,
+ * which held only as long as every single site remembered to divide.
+ */
+ @Test
+ fun `a station dot is the same size at every zoom`() {
+ val widths = listOf(6.0f, 6.3f, 6.5f, 6.9f, 11.5f, 16.25f).map { widestRun(render(it), dotBorder) }
+
+ assertTrue(widths.all { it > 0 }) { "no station dot was drawn at all: $widths" }
+ assertEquals(1, widths.distinct().size, "the dot changed size across zooms: $widths")
+ }
+
+ /**
+ * Home, the beam and the path line have to sit in the same coordinate system as the
+ * stations.
+ *
+ * In the previous version they did not: `scale(scaleFactor)` closed before the beam
+ * block, which then used the unscaled formula while the markers above it had been
+ * drawn scaled. At an integer zoom the factor is 1 and the two agreed; at any zoom
+ * with a fraction the beam started somewhere other than home.
+ */
+ @Test
+ fun `home sits exactly on a station at the same position, at every zoom`() {
+ /* Off the centre of the view on purpose: at the centre a scaling error is zero. */
+ val home = kst4contest.locatorUtils.Location("JO50JP")
+ val homeLat = home.latitude.toDegrees()
+ val homeLon = home.longitude.toDegrees()
+ val stationOnTopOfHome = listOf(station("DL0HOME", "JO50JP", homeLat, homeLon))
+
+ for (zoom in listOf(6f, 6.5f, 7.25f, 8.25f)) {
+ /*
+ * Two renders of the same place rather than one: the station dot is drawn
+ * over the beam layer and would simply hide the home dot.
+ */
+ val withStationOnly = render(zoom, markers = stationOnTopOfHome, ownLocator6 = "")
+ val withHomeOnly = render(zoom, markers = emptyList(), ownLocator6 = "JO50JP")
+
+ val homeCentre = centroidOf(withHomeOnly, homeColour)
+ val stationCentre = centroidOf(withStationOnly, dotBorder)
+
+ assertTrue(homeCentre != null && stationCentre != null) {
+ "at zoom $zoom: home=$homeCentre station=$stationCentre"
+ }
+ assertTrue(
+ abs(homeCentre!!.first - stationCentre!!.first) <= 1.0 &&
+ abs(homeCentre.second - stationCentre.second) <= 1.0
+ ) {
+ "at zoom $zoom home sits at $homeCentre but the station at the same place is at $stationCentre"
+ }
+ }
+ }
+
+ @Test
+ fun `an unreachable tile source leaves the map in its own colour, not the system grey`() {
+ val image = render(6f, markers = emptyList())
+
+ assertTrue(countColour(image, darkMapBackground) > image.width * image.height / 2) {
+ "the map background should cover the canvas while no tiles have arrived"
+ }
+ }
+
+ /** `#63067a`, centred in the cell — not white, not in the corner. */
+ @Test
+ fun `grid labels are drawn in the colour the Leaflet map used`() {
+ assertTrue(countColour(render(6f), gridLabel) > 0) { "no grid label was drawn" }
+ }
+
+ /**
+ * At six-digit precision the grid has far more cells than it has room for labels.
+ * The planner answers that with a stride; without it every cell got one and the map
+ * disappeared under text, which is what the 30 September screenshot shows.
+ */
+ @Test
+ fun `grid labels stay sparse where the cells are small`() {
+ val image = render(12f)
+ val labelPixels = countColour(image, gridLabel)
+ val canvasPixels = image.width * image.height
+
+ assertTrue(labelPixels < canvasPixels / 20) {
+ "grid labels cover $labelPixels of $canvasPixels pixels, which is a carpet rather than a grid"
+ }
+ }
+
+ /** Four-digit squares at zoom 6, two-digit fields at zoom 4. */
+ @Test
+ fun `the grid gets finer as the map zooms in`() {
+ val atFour = countColour(render(4f), gridLabel)
+ val atSix = countColour(render(6f), gridLabel)
+
+ assertTrue(atFour > 0 && atSix > 0) { "labels missing: zoom 4 -> $atFour, zoom 6 -> $atSix" }
+ assertTrue(atSix > atFour) {
+ "a four-digit grid should show more labels than a two-digit one: $atSix vs $atFour"
+ }
+ }
+
+ /**
+ * Every grid line has to be drawn once.
+ *
+ * One stroked rectangle per cell paints each interior line twice — once by the cell
+ * on either side — so at the specified 0.48 a shared edge reaches about 0.73 while
+ * the outermost line stays at 0.48. The result is a grid of visibly uneven weight,
+ * which is the kind of thing that makes the map not look like the original. Leaflet
+ * drew each line once.
+ *
+ * Asserted as "no pixel carries more line colour than one full pass can put there".
+ * Antialiasing of a 1.4 px line at a fractional position legitimately produces many
+ * shades BELOW that value; none can legitimately exceed it.
+ */
+ @Test
+ fun `no grid line is painted over itself`() {
+ /* #e1e7ec at 0.48 over #23282d — one pass, fully covering a pixel. */
+ val singlePassRed = (0.48 * 0xE1 + 0.52 * 0x23).toInt()
+
+ val image = render(6f, markers = emptyList())
+
+ /*
+ * Only rows that cross vertical lines. A row lying on a horizontal grid line is
+ * bright across its whole width, and where the two cross they legitimately
+ * overlap — Leaflet's polylines crossed too.
+ */
+ var brightest = 0
+ for (y in 0 until image.height) {
+ var lit = 0
+ var rowBrightest = 0
+ for (x in 0 until image.width) {
+ val pixel = image.getRGB(x, y) and 0xFFFFFF
+ if (pixel == darkMapBackground) continue
+ lit++
+ val red = (pixel shr 16) and 0xFF
+ if (red > rowBrightest) rowBrightest = red
+ }
+ val crossesAHorizontalLine = lit > image.width / 10
+ if (!crossesAHorizontalLine && rowBrightest > brightest) brightest = rowBrightest
+ }
+
+ assertTrue(brightest > 0) { "no grid line was drawn at all" }
+ assertTrue(brightest <= singlePassRed + 2) {
+ "a grid pixel reached $brightest where one pass tops out at $singlePassRed — " +
+ "the line was painted more than once"
+ }
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/ComposeStationMapLayoutTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/ComposeStationMapLayoutTest.kt
new file mode 100644
index 00000000..e4adeaa0
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/ComposeStationMapLayoutTest.kt
@@ -0,0 +1,80 @@
+package kst4contest.view.compose.map
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.ui.ImageComposeScene
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.Density
+import kst4contest.view.map.MapCallsignRawSnapshot
+import kst4contest.view.map.TileFetcher
+import org.junit.jupiter.api.Test
+
+/**
+ * The map canvas has to survive every zoom and every canvas size.
+ *
+ * Markers carry labels, and a label is measured against the canvas it is drawn on. A
+ * label whose anchor has drifted outside the canvas makes Compose build text
+ * constraints from a negative remaining width, which is not a Constraints value that
+ * exists. Fractional zoom levels are included deliberately: the canvas scales its whole
+ * drawing by the fraction, so sizes that are fine at zoom 6 are not at zoom 6.5.
+ */
+class ComposeStationMapLayoutTest {
+
+ private fun station(call: String, lat: Double, lon: Double, selected: Boolean = false) =
+ MapCallsignRawSnapshot(
+ call, call, "JO50JP", lat, lon, "144, 432", emptyMap(),
+ true, false, false, selected, 100.0, 90.0, 0, 0L,
+ )
+
+ private val stations = listOf(
+ station("DN9APW", 50.5, 10.5),
+ station("DL3JIN", 50.9, 11.9),
+ station("F5JMI", 44.0, 5.0, selected = true),
+ station("SM7KOJ", 60.0, 15.0),
+ )
+
+ private fun renderAt(widthPx: Int, heightPx: Int, zoom: Float) {
+ val scene = ImageComposeScene(
+ width = widthPx,
+ height = heightPx,
+ density = Density(1f),
+ ) {
+ ComposeStationMap(
+ markers = stations,
+ tileFetcher = TileFetcher(),
+ mapState = MapState(zoom = zoom, centerLon = 10.0, centerLat = 51.0),
+ groupingEnabled = true,
+ selectedMarkerCallsign = "F5JMI",
+ ownLocator6 = "JO50JP",
+ antennaAzimuthDeg = 229.0,
+ antennaBeamWidthDeg = 60.0,
+ maxQrbKm = 800.0,
+ onMarkerClicked = null,
+ darkMode = true,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+ try {
+ scene.render()
+ } finally {
+ scene.close()
+ }
+ }
+
+ @Test
+ fun `renders at a normal window size and integer zoom`() = renderAt(900, 600, 6f)
+
+ @Test
+ fun `renders at a fractional zoom`() = renderAt(900, 600, 6.5f)
+
+ @Test
+ fun `renders when the canvas is narrow`() = renderAt(40, 600, 6.5f)
+
+ @Test
+ fun `renders when the canvas is short`() = renderAt(900, 30, 6.5f)
+
+ @Test
+ fun `renders at the highest zoom`() = renderAt(900, 600, 17.5f)
+
+ @Test
+ fun `renders at one pixel`() = renderAt(1, 1, 6.5f)
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/PathAnalysisDetailsLayoutTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/PathAnalysisDetailsLayoutTest.kt
new file mode 100644
index 00000000..77e8b77e
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/PathAnalysisDetailsLayoutTest.kt
@@ -0,0 +1,65 @@
+package kst4contest.view.compose.map
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.ui.ImageComposeScene
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.Density
+import kst4contest.view.map.PathAnalysisResult
+import org.junit.jupiter.api.Test
+
+/**
+ * The analysis column has to survive being narrow.
+ *
+ * The station map splitter can be dragged until this column is a sliver, and the
+ * window may be resized below any width the layout assumed. Both happened, and both
+ * ended in an IllegalArgumentException out of Constraints rather than a squeezed
+ * column — the window then put up an error dialog over the map.
+ *
+ * Rendering is the assertion: ImageComposeScene measures, lays out and draws, which is
+ * exactly where invalid constraints are built. It ships with compose.desktop.currentOs,
+ * so this needs no test dependency the project does not already have.
+ */
+class PathAnalysisDetailsLayoutTest {
+
+ /** Narrower than the 130 dp the assessment label used to reserve for itself. */
+ private val narrowWidthPx = 120
+
+ private fun renderAt(widthPx: Int, result: PathAnalysisResult?) {
+ val scene = ImageComposeScene(
+ width = widthPx,
+ height = 600,
+ density = Density(1f),
+ ) {
+ PathAnalysisDetails(
+ result = result,
+ darkMode = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ try {
+ scene.render()
+ } finally {
+ scene.close()
+ }
+ }
+
+ @Test
+ fun `renders when the column is narrower than the assessment label`() {
+ renderAt(narrowWidthPx, PathAnalysisResult.loading("JO50JP", "JN24JB", "F5JMI"))
+ }
+
+ @Test
+ fun `renders at a width no operator could drag it below`() {
+ renderAt(1, PathAnalysisResult.loading("JO50JP", "JN24JB", "F5JMI"))
+ }
+
+ @Test
+ fun `renders without a result`() {
+ renderAt(narrowWidthPx, null)
+ }
+
+ @Test
+ fun `renders at a comfortable width`() {
+ renderAt(350, PathAnalysisResult.loading("JO50JP", "JN24JB", "F5JMI"))
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/PathAnalysisDetailsWidthTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/PathAnalysisDetailsWidthTest.kt
new file mode 100644
index 00000000..3de304f9
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/PathAnalysisDetailsWidthTest.kt
@@ -0,0 +1,83 @@
+package kst4contest.view.compose.map
+
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.ui.ImageComposeScene
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.Density
+import kst4contest.view.map.PathAnalysisResult
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+import java.awt.image.BufferedImage
+import java.io.ByteArrayInputStream
+import javax.imageio.ImageIO
+
+/**
+ * The value column keeps its share of a narrow analysis pane.
+ *
+ * Every row of this column pairs a label with a value, and the value is what an
+ * operator reads: a locator, a distance, a propagation assessment. The JavaFX original
+ * used a GridPane whose value column took the remaining width. The Compose port gave
+ * the assessment label a fixed 130 dp instead, so once the splitter narrowed the pane
+ * the label kept its 130 dp and the assessment was squeezed — visible as clipped text
+ * in the 30 September screenshot.
+ *
+ * Measured on the rendered image rather than through a callback, so the production
+ * composable carries nothing that exists only for this test. The assessment box is the
+ * one opaque coloured band in the column, which makes its left edge findable.
+ */
+class PathAnalysisDetailsWidthTest {
+
+ /** The severity-0 assessment background in dark mode, as PathAnalysisDetails paints it. */
+ private val assessmentBackground = 0x33383E
+
+ private fun render(paneWidthPx: Int, heightPx: Int = 600): BufferedImage {
+ val scene = ImageComposeScene(width = paneWidthPx, height = heightPx, density = Density(1f)) {
+ PathAnalysisDetails(
+ result = PathAnalysisResult.loading("JO50JP", "JN24JB", "F5JMI"),
+ darkMode = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ try {
+ val png = scene.render().encodeToData()!!.bytes
+ return ImageIO.read(ByteArrayInputStream(png))
+ } finally {
+ scene.close()
+ }
+ }
+
+ /** The smallest x at which the assessment background appears, or -1 when it does not. */
+ private fun assessmentLeftEdge(image: BufferedImage): Int {
+ for (x in 0 until image.width) {
+ for (y in 0 until image.height) {
+ if ((image.getRGB(x, y) and 0xFFFFFF) == assessmentBackground) return x
+ }
+ }
+ return -1
+ }
+
+ @Test
+ fun `the assessment value starts where every other value starts`() {
+ val paneWidth = 200
+ val leftEdge = assessmentLeftEdge(render(paneWidth))
+
+ assertTrue(leftEdge >= 0) { "the assessment box was not drawn at all" }
+ assertTrue(
+ leftEdge <= paneWidth * 0.45,
+ {
+ "the assessment value starts at x=$leftEdge of $paneWidth px; " +
+ "DetailRow starts its values at about 38%, so this column is out of line"
+ },
+ )
+ }
+
+ /**
+ * The real column scrolls, so height is not what limits it. At 60 px every value
+ * above wraps into a stack of lines and pushes this row far down; the height here
+ * is generous for that reason, not because the pane is ever this tall.
+ */
+ @Test
+ fun `the assessment box is still drawn in a sliver of a pane`() {
+ assertTrue(assessmentLeftEdge(render(60, heightPx = 3000)) >= 0)
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/PathProfileChartLayoutTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/PathProfileChartLayoutTest.kt
new file mode 100644
index 00000000..3884b9b6
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/PathProfileChartLayoutTest.kt
@@ -0,0 +1,46 @@
+package kst4contest.view.compose.map
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.ui.ImageComposeScene
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.Density
+import org.junit.jupiter.api.Test
+
+/**
+ * The terrain profile chart has to survive being small.
+ *
+ * It is the only part of the map window that draws text onto a canvas whose size it
+ * does not control: the splitter above it and the window around it both shrink it. A
+ * canvas narrower than the chart's own margins puts the axis labels outside the canvas,
+ * and Compose builds the text measurement constraints from the distance between the
+ * label and the canvas edge — a distance that is negative once the label is outside.
+ */
+class PathProfileChartLayoutTest {
+
+ private fun renderAt(widthPx: Int, heightPx: Int) {
+ val scene = ImageComposeScene(
+ width = widthPx,
+ height = heightPx,
+ density = Density(1f),
+ ) {
+ PathProfileChart(modifier = Modifier.fillMaxSize(), darkMode = true)
+ }
+ try {
+ scene.render()
+ } finally {
+ scene.close()
+ }
+ }
+
+ @Test
+ fun `renders at the height the window gives it`() = renderAt(800, 210)
+
+ @Test
+ fun `renders when narrower than its own left margin`() = renderAt(40, 210)
+
+ @Test
+ fun `renders when shorter than its own header`() = renderAt(800, 20)
+
+ @Test
+ fun `renders at one pixel`() = renderAt(1, 1)
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/StationMapBodyLayoutTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/StationMapBodyLayoutTest.kt
new file mode 100644
index 00000000..3e44f7fd
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/StationMapBodyLayoutTest.kt
@@ -0,0 +1,119 @@
+package kst4contest.view.compose.map
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.ImageComposeScene
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.Density
+import androidx.compose.ui.unit.dp
+import kst4contest.view.compose.SplitterOrientation
+import kst4contest.view.compose.SplitterPane
+import kst4contest.view.compose.SplitterState
+import kst4contest.view.map.MapCallsignRawSnapshot
+import kst4contest.view.map.PathAnalysisResult
+import kst4contest.view.map.TileFetcher
+import org.junit.jupiter.api.Test
+
+/**
+ * The map window's body, assembled the way the window assembles it.
+ *
+ * Each part of this body renders on its own at one pixel. The window still put up a
+ * Constraints error dialog, and the answer was a barrier that leaves the window blank
+ * below 500x400 dp — so the combination is what was never checked. The splitter hands
+ * its panes a width it computed, the left pane stacks a weighted canvas above a fixed
+ * 210 dp chart, and the right pane scrolls. That arrangement is this test.
+ */
+class StationMapBodyLayoutTest {
+
+ private val stations = listOf(
+ MapCallsignRawSnapshot(
+ "DN9APW", "DN9APW", "JO50JP", 50.5, 10.5, "144, 432", emptyMap(),
+ true, false, false, false, 100.0, 90.0, 0, 0L,
+ ),
+ )
+
+ @Composable
+ private fun Body(pathAnalysisVisible: Boolean) {
+ val splitter = SplitterState(paneCount = 2, stored = doubleArrayOf(0.65), save = { })
+
+ val leftPane: @Composable () -> Unit = {
+ Column(Modifier.fillMaxHeight()) {
+ ComposeStationMap(
+ markers = stations,
+ tileFetcher = TileFetcher(),
+ mapState = MapState(zoom = 6.5f, centerLon = 10.0, centerLat = 51.0),
+ groupingEnabled = true,
+ selectedMarkerCallsign = null,
+ ownLocator6 = "JO50JP",
+ antennaAzimuthDeg = 229.0,
+ antennaBeamWidthDeg = 60.0,
+ maxQrbKm = 800.0,
+ onMarkerClicked = null,
+ darkMode = true,
+ modifier = Modifier.weight(1f).fillMaxWidth(),
+ )
+ if (pathAnalysisVisible) {
+ PathProfileChart(
+ modifier = Modifier.fillMaxWidth().height(210.dp),
+ darkMode = true,
+ )
+ }
+ }
+ }
+
+ val rightPane: @Composable () -> Unit = {
+ PathAnalysisDetails(
+ result = PathAnalysisResult.loading("JO50JP", "JN24JB", "F5JMI"),
+ darkMode = true,
+ modifier = Modifier.fillMaxHeight().verticalScroll(rememberScrollState()),
+ )
+ }
+
+ if (pathAnalysisVisible) {
+ SplitterPane(
+ state = splitter,
+ orientation = SplitterOrientation.HORIZONTAL,
+ panes = listOf(leftPane, rightPane),
+ modifier = Modifier.fillMaxSize(),
+ )
+ } else {
+ leftPane()
+ }
+ }
+
+ private fun renderAt(widthPx: Int, heightPx: Int, pathAnalysisVisible: Boolean = true) {
+ val scene = ImageComposeScene(width = widthPx, height = heightPx, density = Density(1f)) {
+ Body(pathAnalysisVisible)
+ }
+ try {
+ scene.render()
+ } finally {
+ scene.close()
+ }
+ }
+
+ @Test
+ fun `renders at the window's minimum size`() = renderAt(900, 600)
+
+ /** Below the barrier that commit e7c8a9b9 installed. */
+ @Test
+ fun `renders below the barrier that hid the defect`() = renderAt(499, 399)
+
+ @Test
+ fun `renders when shorter than the chart it stacks`() = renderAt(900, 150)
+
+ @Test
+ fun `renders when the detail pane is a sliver`() = renderAt(120, 400)
+
+ @Test
+ fun `renders without the path analysis`() = renderAt(300, 200, pathAnalysisVisible = false)
+
+ @Test
+ fun `renders at one pixel`() = renderAt(1, 1)
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/TileBitmapCacheTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/TileBitmapCacheTest.kt
new file mode 100644
index 00000000..eabe0e64
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/TileBitmapCacheTest.kt
@@ -0,0 +1,82 @@
+package kst4contest.view.compose.map
+
+import androidx.compose.ui.graphics.ImageBitmap
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertNotNull
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Test
+
+/**
+ * The decoded tiles the map draws from.
+ *
+ * A decoded 256x256 tile is about 262 KB, against roughly 20 KB for the PNG it came
+ * from. The encoded cache below this one is capped at 2048 entries; this one was not
+ * capped at all, so an evening of panning at zoom 12 to 15 retained hundreds of
+ * megabytes of bitmaps that will never be looked at again.
+ */
+class TileBitmapCacheTest {
+
+ private fun bitmap() = ImageBitmap(1, 1)
+
+ @Test
+ fun `a tile that was put in comes back out`() {
+ val cache = TileBitmapCache(maxEntries = 4)
+ val tile = bitmap()
+
+ cache.put("6/1/1", tile)
+
+ assertEquals(tile, cache["6/1/1"])
+ }
+
+ @Test
+ fun `an unknown tile is absent rather than an error`() {
+ assertNull(TileBitmapCache(maxEntries = 4)["6/1/1"])
+ }
+
+ @Test
+ fun `the cache does not grow past its bound`() {
+ val cache = TileBitmapCache(maxEntries = 3)
+
+ repeat(10) { cache.put("6/$it/0", bitmap()) }
+
+ assertEquals(3, cache.size)
+ }
+
+ /** The one dropped is the one that has gone longest without being drawn. */
+ @Test
+ fun `the least recently drawn tile is the one dropped`() {
+ val cache = TileBitmapCache(maxEntries = 3)
+ cache.put("a", bitmap())
+ cache.put("b", bitmap())
+ cache.put("c", bitmap())
+
+ /* Drawing "a" again makes "b" the oldest. */
+ cache["a"]
+ cache.put("d", bitmap())
+
+ assertNotNull(cache["a"])
+ assertNull(cache["b"])
+ assertNotNull(cache["c"])
+ assertNotNull(cache["d"])
+ }
+
+ @Test
+ fun `a tile that turned out to be unusable can be removed`() {
+ val cache = TileBitmapCache(maxEntries = 3)
+ cache.put("6/1/1", bitmap())
+
+ cache.remove("6/1/1")
+
+ assertNull(cache["6/1/1"])
+ assertEquals(0, cache.size)
+ }
+
+ @Test
+ fun `a bound of zero keeps nothing rather than everything`() {
+ val cache = TileBitmapCache(maxEntries = 0)
+
+ cache.put("6/1/1", bitmap())
+
+ assertEquals(0, cache.size)
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/TileKeyTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/TileKeyTest.kt
new file mode 100644
index 00000000..db881d4a
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/TileKeyTest.kt
@@ -0,0 +1,64 @@
+package kst4contest.view.compose.map
+
+import kst4contest.view.map.MapViewport
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+
+/**
+ * Which tiles a view needs.
+ *
+ * The world wraps east to west; the tile index does not. A view straddling the
+ * antimeridian asks for indices outside the valid range, and asking the tile service
+ * for one of those is a 404 — a hole in the map rather than a wrapped tile.
+ */
+class TileKeyTest {
+
+ @Test
+ fun `a tile index past the eastern edge comes back round to the west`() {
+ assertEquals(0, wrapTileX(64, 63))
+ assertEquals(1, wrapTileX(65, 63))
+ assertEquals(63, wrapTileX(-1, 63))
+ assertEquals(62, wrapTileX(-2, 63))
+ assertEquals(0, wrapTileX(-64, 63))
+ }
+
+ @Test
+ fun `a tile index inside the range is left alone`() {
+ assertEquals(0, wrapTileX(0, 63))
+ assertEquals(33, wrapTileX(33, 63))
+ assertEquals(63, wrapTileX(63, 63))
+ }
+
+ @Test
+ fun `every tile a view asks for exists at that zoom`() {
+ for (zoom in 3..10) {
+ val lastIndex = (1 shl zoom) - 1
+ /* Hard against the antimeridian, where the wrap actually happens. */
+ val viewport = MapViewport(179.5, 0.0, zoom.toDouble(), 1200.0, 800.0)
+
+ val keys = visibleTileKeys(viewport)
+
+ assertTrue(keys.isNotEmpty()) { "no tiles requested at zoom $zoom" }
+ assertTrue(keys.all { it.x in 0..lastIndex && it.y in 0..lastIndex }) {
+ "zoom $zoom asked for " + keys.filter { it.x !in 0..lastIndex || it.y !in 0..lastIndex }
+ }
+ assertTrue(keys.all { it.zoom == zoom })
+ }
+ }
+
+ @Test
+ fun `a view beyond the poles asks for no tiles above or below the world`() {
+ val viewport = MapViewport(10.0, 84.0, 3.0, 1200.0, 800.0)
+
+ val keys = visibleTileKeys(viewport)
+ val lastIndex = (1 shl 3) - 1
+
+ assertTrue(keys.all { it.y in 0..lastIndex }) { "asked for a row that does not exist: $keys" }
+ }
+
+ @Test
+ fun `the cache key is the path the tile service uses`() {
+ assertEquals("6/33/21", TileKey(6, 33, 21).cacheKey)
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/AirscoutTabStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/AirscoutTabStateTest.kt
new file mode 100644
index 00000000..5a9c8e5e
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/AirscoutTabStateTest.kt
@@ -0,0 +1,132 @@
+package kst4contest.view.compose.tabs
+
+import kst4contest.model.ChatPreferences
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+
+class AirscoutTabStateTest {
+
+ private class Session {
+ val prefs = ChatPreferences()
+ val state by lazy { AirscoutTabState(prefs) }
+ }
+
+ @Test
+ fun `reading comes straight from the preferences`() {
+ val s = Session()
+ s.prefs.setAirScout_asUDPListenerEnabled(true)
+ s.prefs.setAirScout_asServerNameString("AS")
+ s.prefs.setAirScout_asClientNameString("KST")
+ s.prefs.setAirScout_asCommunicationPort(9872)
+ s.prefs.setAirScout_autoBandSelectionEnabled(true)
+ s.prefs.setAirScout_asBandString("1440000")
+
+ assertTrue(s.state.airScout_asUDPListenerEnabled)
+ assertEquals("AS", s.state.airScout_asServerNameString)
+ assertEquals("KST", s.state.airScout_asClientNameString)
+ assertEquals(9872, s.state.airScout_asCommunicationPort)
+ assertTrue(s.state.airScout_autoBandSelectionEnabled)
+ assertEquals("1440000", s.state.airScout_asBandString)
+ }
+
+ @Test
+ fun `writing reaches the preferences at once, without a confirmation step`() {
+ val s = Session()
+
+ s.state.airScout_asUDPListenerEnabled = true
+ s.state.airScout_asServerNameString = "AS2"
+ s.state.airScout_asClientNameString = "KST2"
+ s.state.airScout_asCommunicationPort = 9873
+ s.state.airScout_autoBandSelectionEnabled = true
+ s.state.airScout_asBandString = "4320000"
+
+ assertTrue(s.prefs.isAirScout_asUDPListenerEnabled())
+ assertEquals("AS2", s.prefs.getAirScout_asServerNameString())
+ assertEquals("KST2", s.prefs.getAirScout_asClientNameString())
+ assertEquals(9873, s.prefs.getAirScout_asCommunicationPort())
+ assertTrue(s.prefs.isAirScout_autoBandSelectionEnabled())
+ assertEquals("4320000", s.prefs.getAirScout_asBandString())
+ }
+
+ @Test
+ fun `identifiers are stored trimmed`() {
+ val s = Session()
+
+ s.state.airScout_asServerNameString = " AS "
+ s.state.airScout_asClientNameString = " KST "
+
+ assertEquals("AS", s.prefs.getAirScout_asServerNameString())
+ assertEquals("KST", s.prefs.getAirScout_asClientNameString())
+ }
+
+ @Test
+ fun `an invalid identifier keeps the configured one instead of falling back`() {
+ // The AirScout protocol carries these identifiers inside quoted fields:
+ // empty, quotation marks and line breaks are what the JavaFX window rejected.
+ // Guarding before the write matters because the ChatPreferences setter does
+ // not keep the old value on bad input, it substitutes the factory default —
+ // an unguarded keystroke would silently reset the operator's identifier.
+ val s = Session()
+ s.prefs.setAirScout_asServerNameString("AS7")
+ s.prefs.setAirScout_asClientNameString("KST7")
+
+ for (rejected in listOf("", " ", "A\"S", "A\rS", "A\nS")) {
+ s.state.airScout_asServerNameString = rejected
+ s.state.airScout_asClientNameString = rejected
+ assertFalse(AirscoutTabState.isValidIdentifier(rejected), "must be rejected: <$rejected>")
+ }
+
+ assertEquals("AS7", s.prefs.getAirScout_asServerNameString())
+ assertEquals("KST7", s.prefs.getAirScout_asClientNameString())
+ }
+
+ @Test
+ fun `a port outside the accepted range keeps the configured one`() {
+ val s = Session()
+ s.prefs.setAirScout_asCommunicationPort(9999)
+
+ s.state.airScout_asCommunicationPort = 0
+ s.state.airScout_asCommunicationPort = 65536
+
+ assertEquals(9999, s.prefs.getAirScout_asCommunicationPort(),
+ "the ChatPreferences setter would have substituted 9872 here")
+
+ s.state.airScout_asCommunicationPort = 1
+ assertEquals(1, s.prefs.getAirScout_asCommunicationPort())
+
+ s.state.airScout_asCommunicationPort = 65535
+ assertEquals(65535, s.prefs.getAirScout_asCommunicationPort(),
+ "the JavaFX field accepted 1..65535; the range must not get narrower")
+ }
+
+ @Test
+ fun `the band value is stored as the protocol spells it`() {
+ val s = Session()
+ s.prefs.setAirScout_asBandString("1440000")
+
+ s.state.airScout_asBandString = " 04320000 "
+ assertEquals("4320000", s.prefs.getAirScout_asBandString(),
+ "the JavaFX field stored Long.toString of the parsed value")
+
+ s.state.airScout_asBandString = "0"
+ s.state.airScout_asBandString = "-1"
+ s.state.airScout_asBandString = "144 MHz"
+ assertEquals("4320000", s.prefs.getAirScout_asBandString(),
+ "a value that is not a positive number was rejected, not stored — the "
+ + "ChatPreferences setter would have substituted 1440000 here")
+ }
+
+ @Test
+ fun `the forced band value is only editable while automatic selection is off`() {
+ val s = Session()
+
+ s.state.airScout_autoBandSelectionEnabled = true
+ assertFalse(s.state.bandValueEditable,
+ "the JavaFX band field was disabled while auto per station was selected")
+
+ s.state.airScout_autoBandSelectionEnabled = false
+ assertTrue(s.state.bandValueEditable)
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/BeaconTabStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/BeaconTabStateTest.kt
new file mode 100644
index 00000000..c531ab33
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/BeaconTabStateTest.kt
@@ -0,0 +1,138 @@
+package kst4contest.view.compose.tabs
+
+import kst4contest.controller.ChatController
+import kst4contest.model.ChatPreferences
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertNotNull
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Assertions.assertTrue
+
+class BeaconTabStateTest {
+
+ private class Session(private val templateRefusal: String? = null) {
+ val prefs = ChatPreferences()
+ var timerRestarts = 0
+ var validated = mutableListOf()
+
+ fun state() = BeaconTabState(
+ prefs,
+ validateBeaconTemplate = { template ->
+ validated.add(template)
+ templateRefusal?.let { throw IllegalArgumentException(it) }
+ },
+ restartBeaconTimer = { timerRestarts++ },
+ mainCategoryName = "144 MHz and up",
+ secondCategoryName = "50 MHz",
+ )
+ }
+
+ @Test
+ fun `the enable flags write through at once`() {
+ val s = Session()
+ val state = s.state()
+
+ state.bcn_beaconsEnabledMainCat = true
+ state.bcn_beaconsEnabledSecondCat = true
+
+ assertTrue(s.prefs.isBcn_beaconsEnabledMainCat())
+ assertTrue(s.prefs.isBcn_beaconsEnabledSecondCat())
+
+ state.bcn_beaconsEnabledSecondCat = false
+ assertFalse(s.prefs.isBcn_beaconsEnabledSecondCat())
+ assertTrue(s.prefs.isBcn_beaconsEnabledMainCat())
+ }
+
+ @Test
+ fun `a valid beacon template is validated and stored verbatim`() {
+ val s = Session()
+ val state = s.state()
+ val template = "CQ MYCALL MYLOC QRV MYQRG"
+
+ assertNull(state.commitBeaconTextMainCat(template))
+
+ assertEquals(template, s.prefs.getBcn_beaconTextMainCat(),
+ "MYCALL and MYQRG are resolved by MessageVariableResolver; "
+ + "any rewriting would break that silently")
+ assertEquals(listOf(template), s.validated)
+ }
+
+ @Test
+ fun `the second category has its own template`() {
+ val s = Session()
+ val state = s.state()
+
+ state.commitBeaconTextSecondCat("CQ MYCALL on MYQRG")
+
+ assertEquals("CQ MYCALL on MYQRG", s.prefs.getBcn_beaconTextSecondCat())
+ assertEquals("Hi, pse call us", s.prefs.getBcn_beaconTextMainCat(),
+ "the main-category template is untouched")
+ }
+
+ @Test
+ fun `a refused template is reported and the stored one stands`() {
+ val s = Session("Message too long")
+ s.prefs.setBcn_beaconTextMainCat("CQ MYCALL")
+ val state = s.state()
+
+ val refusal = state.commitBeaconTextMainCat("x".repeat(500))
+
+ assertNotNull(refusal)
+ assertEquals("CQ MYCALL", s.prefs.getBcn_beaconTextMainCat(),
+ "the JavaFX field restored the previous template on an alert")
+ }
+
+ @Test
+ fun `the shared interval never reads below the minimum`() {
+ val s = Session()
+ s.prefs.setBcn_beaconIntervalInMinutesMainCat(0)
+
+ assertEquals(ChatController.MIN_BEACON_INTERVAL_MINUTES, s.state().beaconIntervalMinutes)
+ }
+
+ @Test
+ fun `a valid interval is written to both categories and restarts the timer`() {
+ val s = Session()
+ val state = s.state()
+
+ assertNull(state.commitBeaconInterval("7"))
+
+ assertEquals(7, s.prefs.getBcn_beaconIntervalInMinutesMainCat())
+ assertEquals(7, s.prefs.getBcn_beaconIntervalInMinutesSecondCat(),
+ "the legacy second-category value stays synchronized with the shared interval")
+ assertEquals(1, s.timerRestarts)
+ }
+
+ @Test
+ fun `an interval below the minimum is refused and the timer is left alone`() {
+ val s = Session()
+ s.prefs.setBcn_beaconIntervalInMinutesMainCat(5)
+ val state = s.state()
+
+ assertNotNull(state.commitBeaconInterval("0"))
+
+ assertEquals(5, s.prefs.getBcn_beaconIntervalInMinutesMainCat())
+ assertEquals(0, s.timerRestarts,
+ "restarting the timer per rejected keystroke would disturb the cooldown")
+ }
+
+ @Test
+ fun `an interval that is not a whole number is refused`() {
+ val s = Session()
+ s.prefs.setBcn_beaconIntervalInMinutesMainCat(5)
+ val state = s.state()
+
+ assertNotNull(state.commitBeaconInterval("2,5"))
+
+ assertEquals(5, s.prefs.getBcn_beaconIntervalInMinutesMainCat())
+ }
+
+ @Test
+ fun `the category names are the labels the tab shows`() {
+ val s = Session()
+
+ assertEquals("144 MHz and up", s.state().mainCategoryName)
+ assertEquals("50 MHz", s.state().secondCategoryName)
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/GuiOptionsTabStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/GuiOptionsTabStateTest.kt
new file mode 100644
index 00000000..9341a700
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/GuiOptionsTabStateTest.kt
@@ -0,0 +1,49 @@
+package kst4contest.view.compose.tabs
+
+import kst4contest.model.ChatPreferences
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+
+class GuiOptionsTabStateTest {
+
+ @Test
+ fun `reading comes straight from the preferences`() {
+ val prefs = ChatPreferences()
+ prefs.setGUI_darkModeActiveByDefault(true)
+
+ assertTrue(GuiOptionsTabState(prefs).gUI_darkModeActiveByDefault)
+ }
+
+ @Test
+ fun `writing reaches the preferences at once`() {
+ val prefs = ChatPreferences()
+ val state = GuiOptionsTabState(prefs)
+
+ state.guiOptions_defaultFilterPublicMsgs = true
+ state.guiOptions_showFreshCallHintInBandColumns = true
+
+ assertTrue(prefs.isGuiOptions_defaultFilterPublicMsgs())
+ assertTrue(prefs.isGuiOptions_showFreshCallHintInBandColumns())
+ }
+
+ @Test
+ fun `the four startup filters are independent switches`() {
+ // The JavaFX tab offered them as four separate checkboxes, not a choice of
+ // one. Turning them into a radio group would take a combination away that
+ // an operator may have configured.
+ val prefs = ChatPreferences()
+ val state = GuiOptionsTabState(prefs)
+
+ state.guiOptions_defaultFilterPublicMsgs = true
+ state.guiOptions_defaultFilterPmToMe = true
+ state.guiOptions_defaultFilterPmToOther = false
+ state.guiOptions_defaultFilterNothing = false
+
+ assertTrue(prefs.isGuiOptions_defaultFilterPublicMsgs())
+ assertTrue(prefs.isGuiOptions_defaultFilterPmToMe())
+ assertFalse(prefs.isGuiOptions_defaultFilterPmToOther())
+ assertFalse(prefs.isGuiOptions_defaultFilterNothing())
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/LogSynchTabStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/LogSynchTabStateTest.kt
new file mode 100644
index 00000000..7e5afc61
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/LogSynchTabStateTest.kt
@@ -0,0 +1,156 @@
+package kst4contest.view.compose.tabs
+
+import kst4contest.model.ChatPreferences
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+
+class LogSynchTabStateTest {
+
+ private class Session(detected: String? = null) {
+ val prefs = ChatPreferences()
+ var restarts = 0
+ var stops = 0
+ val detectedBroadcast = detected
+
+ fun state() = LogSynchTabState(
+ prefs,
+ restartWintestListener = { restarts++ },
+ stopWintestListener = { stops++ },
+ detectWintestBroadcastAddress = { detectedBroadcast },
+ )
+ }
+
+ @Test
+ fun `reading comes straight from the preferences`() {
+ val s = Session()
+ s.prefs.setLogsynch_fileBasedWkdCallInterpreterEnabled(true)
+ s.prefs.setLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly("/tmp/wkd.txt")
+ s.prefs.setLogsynch_ucxUDPWkdCallListenerEnabled(true)
+ s.prefs.setLogsynch_ucxUDPWkdCallListenerPort(12060)
+ s.prefs.setLogsynch_wintestNetworkPort(9871)
+ s.prefs.setLogsynch_wintestNetworkStationNameOfKST("KST")
+ val state = s.state()
+
+ assertTrue(state.logsynch_fileBasedWkdCallInterpreterEnabled)
+ assertEquals("/tmp/wkd.txt", state.logsynch_fileBasedWkdCallInterpreterFileNameReadOnly)
+ assertTrue(state.logsynch_ucxUDPWkdCallListenerEnabled)
+ assertEquals(12060, state.logsynch_ucxUDPWkdCallListenerPort)
+ assertEquals(9871, state.logsynch_wintestNetworkPort)
+ assertEquals("KST", state.logsynch_wintestNetworkStationNameOfKST)
+ }
+
+ @Test
+ fun `writing reaches the preferences at once, without a confirmation step`() {
+ // The JavaFX window had no value-collecting confirmation: each control wrote
+ // straight into ChatPreferences, and "Save settings" only persisted them.
+ val s = Session()
+ val state = s.state()
+
+ state.logsynch_fileBasedWkdCallInterpreterEnabled = true
+ state.logsynch_fileBasedWkdCallInterpreterFileNameReadOnly = "/home/op/log.adi"
+ state.logsynch_ucxUDPWkdCallListenerEnabled = true
+ state.logsynch_ucxUDPWkdCallListenerPort = 12060
+ state.logsynch_wintestNetworkPort = 9871
+
+ assertTrue(s.prefs.isLogsynch_fileBasedWkdCallInterpreterEnabled())
+ assertEquals("/home/op/log.adi", s.prefs.getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly())
+ assertTrue(s.prefs.isLogsynch_ucxUDPWkdCallListenerEnabled())
+ assertEquals(12060, s.prefs.getLogsynch_ucxUDPWkdCallListenerPort())
+ assertEquals(9871, s.prefs.getLogsynch_wintestNetworkPort())
+ }
+
+ @Test
+ fun `the Win-Test station name and broadcast address are stored trimmed`() {
+ // The JavaFX fields committed getText().trim(); a stray space in a station
+ // name or a broadcast address is a protocol value, not decoration.
+ val s = Session()
+ val state = s.state()
+
+ state.logsynch_wintestNetworkStationNameOfKST = " KST "
+ state.logsynch_wintestNetworkBroadcastAddress = " 192.168.1.255 "
+
+ assertEquals("KST", s.prefs.getLogsynch_wintestNetworkStationNameOfKST())
+ assertEquals("192.168.1.255", s.prefs.getLogsynch_wintestNetworkBroadcastAddress())
+ }
+
+ @Test
+ fun `enabling the Win-Test listener starts it and disabling stops it`() {
+ val s = Session()
+ val state = s.state()
+
+ state.logsynch_wintestNetworkListenerEnabled = true
+
+ assertTrue(s.prefs.isLogsynch_wintestNetworkListenerEnabled())
+ assertEquals(1, s.restarts, "the running listener must follow the setting")
+
+ state.logsynch_wintestNetworkListenerEnabled = false
+
+ assertFalse(s.prefs.isLogsynch_wintestNetworkListenerEnabled())
+ assertEquals(1, s.stops)
+ }
+
+ @Test
+ fun `a committed port change rebinds the listener only while it is enabled`() {
+ // Deliberately not on every keystroke: the port write goes through at once,
+ // but rebinding a UDP socket is bound to the commit, as the JavaFX field's
+ // focus-lost handler did.
+ val s = Session()
+ val state = s.state()
+
+ /*
+ * Switched off explicitly: ChatPreferences ships
+ * logsynch_wintestNetworkListenerEnabled = true (ChatPreferences.java:254), so
+ * a fresh configuration already listens.
+ */
+ state.logsynch_wintestNetworkListenerEnabled = false
+ val restartsAfterDisable = s.restarts
+
+ state.logsynch_wintestNetworkPort = 9872
+ assertEquals(restartsAfterDisable, s.restarts, "a keystroke must not rebind the socket")
+
+ state.commitWintestNetworkPort()
+ assertEquals(restartsAfterDisable, s.restarts, "a disabled listener has nothing to rebind")
+
+ state.logsynch_wintestNetworkListenerEnabled = true
+ val restartsAfterEnable = s.restarts
+ state.logsynch_wintestNetworkPort = 9873
+ state.commitWintestNetworkPort()
+
+ assertEquals(9873, s.prefs.getLogsynch_wintestNetworkPort())
+ assertEquals(restartsAfterEnable + 1, s.restarts)
+ }
+
+ @Test
+ fun `a still-default broadcast address is replaced by the detected one`() {
+ val s = Session(detected = "192.168.178.255")
+ s.prefs.setLogsynch_wintestNetworkBroadcastAddress("255.255.255.255")
+
+ s.state()
+
+ assertEquals("192.168.178.255", s.prefs.getLogsynch_wintestNetworkBroadcastAddress(),
+ "the JavaFX window auto-detected the subnet broadcast while the stored "
+ + "value was still the catch-all default")
+ }
+
+ @Test
+ fun `a broadcast address the operator chose is never overwritten`() {
+ val s = Session(detected = "192.168.178.255")
+ s.prefs.setLogsynch_wintestNetworkBroadcastAddress("10.0.0.255")
+
+ s.state()
+
+ assertEquals("10.0.0.255", s.prefs.getLogsynch_wintestNetworkBroadcastAddress())
+ }
+
+ @Test
+ fun `a failed detection leaves the default in place`() {
+ val s = Session(detected = null)
+ s.prefs.setLogsynch_wintestNetworkBroadcastAddress("255.255.255.255")
+
+ s.state()
+
+ assertEquals("255.255.255.255", s.prefs.getLogsynch_wintestNetworkBroadcastAddress())
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/MessageHandlingTabStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/MessageHandlingTabStateTest.kt
new file mode 100644
index 00000000..4e6ea1a6
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/MessageHandlingTabStateTest.kt
@@ -0,0 +1,65 @@
+package kst4contest.view.compose.tabs
+
+import kst4contest.model.ChatPreferences
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+
+class MessageHandlingTabStateTest {
+
+ @Test
+ fun `the state starts from the stored preferences`() {
+ val prefs = ChatPreferences()
+ prefs.setMessageHandling_autoAnswerEnabled(true)
+ prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(true)
+ prefs.setMessageHandling_autoAnswerTextMainCat("qrv 144.300")
+
+ val state = MessageHandlingTabState(prefs)
+
+ assertTrue(state.autoAnswerEnabled)
+ assertTrue(state.autoAnswerToQRGRequestEnabled)
+ assertEquals("qrv 144.300", state.autoAnswerText)
+ }
+
+ @Test
+ fun `one checkbox drives both category flags`() {
+ // The JavaFX window offered a single checkbox and wrote both preferences from
+ // it; keeping only the main one would leave the second chat on its old value.
+ val prefs = ChatPreferences()
+ val state = MessageHandlingTabState(prefs)
+
+ state.autoAnswerEnabled = true
+
+ assertTrue(prefs.isMessageHandling_autoAnswerEnabled())
+ assertTrue(prefs.isMessageHandling_autoAnswerEnabledSecondCat())
+
+ state.autoAnswerEnabled = false
+
+ assertFalse(prefs.isMessageHandling_autoAnswerEnabled())
+ assertFalse(prefs.isMessageHandling_autoAnswerEnabledSecondCat())
+ }
+
+ @Test
+ fun `one text field drives both category texts`() {
+ val prefs = ChatPreferences()
+ val state = MessageHandlingTabState(prefs)
+
+ state.autoAnswerText = "qrv 432.200"
+
+ assertEquals("qrv 432.200", prefs.getMessageHandling_autoAnswerTextMainCat())
+ assertEquals("qrv 432.200", prefs.getMessageHandling_autoAnswerTextSecondCat())
+ }
+
+ @Test
+ fun `the QRG reply flag writes through on its own`() {
+ val prefs = ChatPreferences()
+ val state = MessageHandlingTabState(prefs)
+
+ state.autoAnswerToQRGRequestEnabled = true
+
+ assertTrue(prefs.isMessageHandling_autoAnswerToQRGRequestEnabled())
+ assertFalse(prefs.isMessageHandling_autoAnswerEnabled(),
+ "the two options are independent")
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/NotificationTabStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/NotificationTabStateTest.kt
new file mode 100644
index 00000000..4b561158
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/NotificationTabStateTest.kt
@@ -0,0 +1,272 @@
+package kst4contest.view.compose.tabs
+
+import kst4contest.model.Band
+import kst4contest.model.ChatMember
+import kst4contest.model.ChatPreferences
+import kst4contest.observe.SimpleRoster
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertNotNull
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Assertions.assertSame
+import org.junit.jupiter.api.Assertions.assertTrue
+
+class NotificationTabStateTest {
+
+ private class Session {
+ val prefs = ChatPreferences()
+ val monitored = SimpleRoster()
+ var serverEnabledApplied: Boolean? = null
+ var portApplied: Int? = null
+ var spotSent: ChatMember? = null
+ var spotRefusal: String? = null
+
+ fun state() = NotificationTabState(
+ prefs,
+ monitored,
+ applyDxClusterServerEnabled = { serverEnabledApplied = it },
+ applyDxClusterServerPort = { portApplied = it },
+ broadcastTestSpot = { spotSent = it; spotRefusal },
+ )
+ }
+
+ @Test
+ fun `the sound settings read from and write to the preferences at once`() {
+ val s = Session()
+ s.prefs.setNotify_playSimpleSounds(true)
+ val state = s.state()
+
+ assertTrue(state.notify_playSimpleSounds)
+
+ state.notify_playSimpleSounds = false
+ state.notify_playCWCallsignsOnRxedPMs = true
+ state.notify_playVoiceCallsignsOnRxedPMs = true
+
+ assertFalse(s.prefs.isNotify_playSimpleSounds())
+ assertTrue(s.prefs.isNotify_playCWCallsignsOnRxedPMs())
+ assertTrue(s.prefs.isNotify_playVoiceCallsignsOnRxedPMs())
+ }
+
+ @Test
+ fun `the band-upgrade hints write through`() {
+ val s = Session()
+ val state = s.state()
+
+ state.notify_bandUpgradeHintOnLogEnabled = true
+ state.notify_bandUpgradePriorityBoostEnabled = true
+
+ assertTrue(s.prefs.isNotify_bandUpgradeHintOnLogEnabled())
+ assertTrue(s.prefs.isNotify_bandUpgradePriorityBoostEnabled())
+ }
+
+ @Test
+ fun `enabling the DX cluster server also tells the running session`() {
+ val s = Session()
+ val state = s.state()
+
+ state.enableDxClusterServer(true)
+
+ assertTrue(s.prefs.isNotify_dxClusterServerEnabled())
+ assertEquals(true, s.serverEnabledApplied,
+ "the JavaFX checkbox started or stopped the server as well, and the "
+ + "settings coverage test cannot see that call")
+ }
+
+ @Test
+ fun `a valid port is stored and handed to the running session`() {
+ val s = Session()
+ val state = s.state()
+
+ assertNull(state.commitDxClusterServerPort("7300"))
+
+ assertEquals(7300, s.prefs.getNotify_dxclusterServerPort())
+ assertEquals(7300, s.portApplied)
+ }
+
+ @Test
+ fun `a port outside the TCP range is refused and nothing is written`() {
+ val s = Session()
+ s.prefs.setNotify_dxclusterServerPort(8000)
+ val state = s.state()
+
+ val refusal = state.commitDxClusterServerPort("70000")
+
+ assertNotNull(refusal, "the JavaFX field alerted and restored the previous port")
+ assertEquals(8000, s.prefs.getNotify_dxclusterServerPort())
+ assertNull(s.portApplied, "an refused port must not restart the server")
+ }
+
+ @Test
+ fun `a port that is not a number is refused`() {
+ val s = Session()
+ s.prefs.setNotify_dxclusterServerPort(8000)
+ val state = s.state()
+
+ assertNotNull(state.commitDxClusterServerPort("acht"))
+ assertEquals(8000, s.prefs.getNotify_dxclusterServerPort())
+ }
+
+ @Test
+ fun `an unchanged port is stored but does not restart the server`() {
+ val s = Session()
+ s.prefs.setNotify_dxclusterServerPort(8000)
+ val state = s.state()
+
+ assertNull(state.commitDxClusterServerPort("8000"))
+
+ assertNull(s.portApplied,
+ "the JavaFX field only restarted the server when the port actually changed")
+ }
+
+ @Test
+ fun `a spotter callsign is upper-cased and stored`() {
+ val s = Session()
+ val state = s.state()
+
+ assertNull(state.commitSpotterCallSign(" do5amf "))
+
+ assertEquals("DO5AMF", s.prefs.getNotify_DXCSrv_SpottersCallSign().get())
+ assertEquals("DO5AMF", state.notify_DXCSrv_SpottersCallSign)
+ }
+
+ @Test
+ fun `a spotter callsign that is no callsign is refused`() {
+ val s = Session()
+ s.prefs.setNotify_DXCSrv_SpottersCallSign("DO5AMF")
+ val state = s.state()
+
+ assertNotNull(state.commitSpotterCallSign("not a call"))
+
+ assertEquals("DO5AMF", s.prefs.getNotify_DXCSrv_SpottersCallSign().get())
+ }
+
+ @Test
+ fun `the fallback band round trips through the stored prefix`() {
+ val s = Session()
+ val state = s.state()
+
+ state.notify_optionalFrequencyPrefix = Band.B_432
+
+ assertEquals("432", s.prefs.getNotify_optionalFrequencyPrefix().get())
+ assertEquals(Band.B_432, state.notify_optionalFrequencyPrefix)
+ }
+
+ @Test
+ fun `an unsupported stored prefix falls back to 144 and is written back`() {
+ val s = Session()
+ s.prefs.setNotify_optionalFrequencyPrefix("4711")
+
+ val state = s.state()
+
+ assertEquals(Band.B_144, state.notify_optionalFrequencyPrefix)
+ assertEquals("144", s.prefs.getNotify_optionalFrequencyPrefix().get(),
+ "the JavaFX window repaired the stored value the same way")
+ }
+
+ @Test
+ fun `the monitoring list starts from the roster`() {
+ val s = Session()
+ s.monitored.setAll(listOf("DN9APW", "DO5AMF"))
+
+ assertEquals(listOf("DN9APW", "DO5AMF"), s.state().monitoredCallSigns.texts)
+ }
+
+ @Test
+ fun `a monitored callsign is appended as its base call`() {
+ val s = Session()
+ val state = s.state()
+
+ assertNull(state.addMonitoredCallSign(" dn9apw-70 "))
+
+ assertEquals(listOf("DN9APW"), state.monitoredCallSigns.texts,
+ "monitoring works on the base call, so every SSID maps to the same entry")
+ assertEquals(listOf("DN9APW"), s.monitored.snapshot(),
+ "the JavaFX button added to the roster at once")
+ }
+
+ @Test
+ fun `a monitored callsign that is no callsign is refused`() {
+ val s = Session()
+ val state = s.state()
+
+ assertNotNull(state.addMonitoredCallSign("hello"))
+
+ assertTrue(state.monitoredCallSigns.texts.isEmpty())
+ }
+
+ @Test
+ fun `the same base call is not monitored twice`() {
+ val s = Session()
+ val state = s.state()
+ state.addMonitoredCallSign("DN9APW")
+
+ assertNotNull(state.addMonitoredCallSign("dn9apw-2"))
+
+ assertEquals(listOf("DN9APW"), state.monitoredCallSigns.texts)
+ }
+
+ @Test
+ fun `editing a monitored entry normalizes it and reaches the roster`() {
+ val s = Session()
+ s.monitored.setAll(listOf("DN9APW"))
+ val state = s.state()
+
+ state.monitoredCallSigns.commitAt(0, "do5amf-2")
+ state.commitMonitoredCallSigns()
+
+ assertEquals(listOf("DO5AMF"), s.monitored.snapshot())
+ }
+
+ @Test
+ fun `an invalid edit of a monitored entry is refused and the roster is untouched`() {
+ val s = Session()
+ s.monitored.setAll(listOf("DN9APW"))
+ val state = s.state()
+
+ val outcome = state.monitoredCallSigns.commitAt(0, "still not a call")
+ state.commitMonitoredCallSigns()
+
+ assertTrue(outcome is kst4contest.view.compose.CommitOutcome.Refused)
+ assertEquals(listOf("DN9APW"), s.monitored.snapshot())
+ }
+
+ @Test
+ fun `clearing a monitored entry removes it from the roster`() {
+ val s = Session()
+ s.monitored.setAll(listOf("DN9APW", "DO5AMF"))
+ val state = s.state()
+ val rosterBefore = s.monitored
+
+ state.monitoredCallSigns.commitAt(0, "")
+ state.commitMonitoredCallSigns()
+
+ assertEquals(listOf("DO5AMF"), s.monitored.snapshot())
+ assertSame(rosterBefore, s.monitored,
+ "the roster is filled, never replaced: a swap would orphan its listeners")
+ }
+
+ @Test
+ fun `the test spot carries the values the JavaFX button used`() {
+ val s = Session()
+ val state = s.state()
+
+ assertNull(state.sendTestSpot())
+
+ val spot = s.spotSent
+ assertNotNull(spot)
+ assertEquals("DO5AMF", spot!!.callSign)
+ assertEquals("300", spot.frequency.get())
+ assertEquals("DXC test: You donated \$100!", spot.qra,
+ "deliberate test data, kept verbatim")
+ }
+
+ @Test
+ fun `a refused test spot reports why`() {
+ val s = Session()
+ s.spotRefusal = "No DX Cluster client is connected to KST4Contest."
+ val state = s.state()
+
+ assertEquals("No DX Cluster client is connected to KST4Contest.", state.sendTestSpot())
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ProfilesTabStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ProfilesTabStateTest.kt
new file mode 100644
index 00000000..50249f53
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ProfilesTabStateTest.kt
@@ -0,0 +1,168 @@
+package kst4contest.view.compose.tabs
+
+import kst4contest.controller.OperatorProfileManagementService
+import kst4contest.controller.OperatorProfileStore
+import kst4contest.model.OperatorProfile
+import kst4contest.model.OperatorProfileSelection
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.io.TempDir
+import java.nio.file.Path
+
+/**
+ * Rules of the profiles tab.
+ *
+ * Creating and deleting profiles is deliberately not exercised here: both reach into
+ * the real application directory under the operator's home, and a test that writes
+ * there could destroy a profile in use. Every rule below refuses before the service
+ * is called, which is exactly the part worth pinning down.
+ */
+class ProfilesTabStateTest {
+
+ @TempDir
+ lateinit var registryDirectory: Path
+
+ private fun service() = OperatorProfileManagementService(
+ OperatorProfileStore(registryDirectory.resolve("profiles.xml").toString())
+ )
+
+ private fun state(
+ active: OperatorProfileSelection? = null,
+ onActivation: (OperatorProfile) -> Unit = { },
+ ) = ProfilesTabState(service(), { active }, onActivation)
+
+ @Test
+ fun `an absent registry still offers the default profile`() {
+ val state = state()
+
+ assertEquals(1, state.profiles.size)
+ assertTrue(state.profiles.first().isRootProfile)
+ assertEquals(state.profiles.first(), state.selected,
+ "the JavaFX table selected the first row when nothing was selected yet")
+ }
+
+ @Test
+ fun `the default profile cannot be deleted`() {
+ val state = state()
+
+ val notice = state.deleteSelected()
+
+ assertTrue(notice is ProfileNotice.Error)
+ assertEquals(
+ "The default profile uses the files of the installation itself and cannot be deleted.",
+ notice!!.message,
+ )
+ assertEquals(1, state.profiles.size, "nothing was removed")
+ }
+
+ @Test
+ fun `the default profile always uses the common worked stations`() {
+ val state = state()
+
+ val notice = state.changeWorkedDataModeOfSelected(false)
+
+ assertTrue(notice is ProfileNotice.Error,
+ "that database is the installation's own, so the choice does not exist")
+ assertFalse(state.canChangeWorkedDataMode(state.profiles.first()))
+ }
+
+ @Test
+ fun `the profile in use cannot be deleted`() {
+ val plainService = service()
+ val profile = plainService.listProfiles().first()
+ val state = ProfilesTabState(
+ plainService,
+ { OperatorProfileSelection(profile, "preferences.xml", "worked.db", false) },
+ { },
+ )
+
+ val notice = state.deleteSelected()
+
+ assertTrue(notice is ProfileNotice.Error)
+ }
+
+ @Test
+ fun `switching to the profile already in use does nothing`() {
+ val plainService = service()
+ val profile = plainService.listProfiles().first()
+ var activations = 0
+ val state = ProfilesTabState(
+ plainService,
+ { OperatorProfileSelection(profile, "preferences.xml", "worked.db", false) },
+ { activations++ },
+ )
+
+ val notice = state.activateSelected()
+
+ assertTrue(notice is ProfileNotice.Info)
+ assertEquals("This profile is already active.", notice!!.message)
+ assertEquals(0, activations,
+ "a switch restarts into the other profile; doing that for no reason would "
+ + "drop the operator out of a running contest")
+ }
+
+ @Test
+ fun `an unchanged worked-stations mode is not written`() {
+ /*
+ * The registry is seeded in the temporary directory so a non-root profile
+ * exists without creating one, which would write into the operator's home.
+ */
+ val store = OperatorProfileStore(registryDirectory.resolve("profiles.xml").toString())
+ val shared = OperatorProfile("p1", "DN9APW", false, true)
+ store.saveProfiles(listOf(shared), shared.profileId)
+
+ val state = ProfilesTabState(OperatorProfileManagementService(store), { null }, { })
+
+ assertEquals(shared, state.selected)
+ assertNull(state.changeWorkedDataModeOfSelected(true),
+ "the mode already is the requested one, so nothing is written and nothing is said")
+ }
+
+ @Test
+ fun `selecting a profile that is not in the list clears the selection`() {
+ val state = state()
+
+ state.select(OperatorProfile("stale", "gone", false, false))
+
+ assertNull(state.selected,
+ "a profile deleted elsewhere must not stay selectable, or an action would "
+ + "operate on a profile that no longer exists")
+ }
+
+ @Test
+ fun `a blank name creates nothing`() {
+ val state = state()
+
+ assertNull(state.createProfile(" ", false))
+ assertEquals(1, state.profiles.size)
+ }
+
+ @Test
+ fun `the duplicate name suggestion is the one the JavaFX dialog pre-filled`() {
+ val state = state()
+
+ assertEquals("Copy of " + state.profiles.first().displayName, state.duplicateNameSuggestion())
+ }
+
+ @Test
+ fun `the worked-stations column names the shared database for the root profile`() {
+ val root = OperatorProfile("root", "Default", true, false)
+
+ assertEquals("common station database", ProfilesTabState.describeWorkedDataMode(root),
+ "the root profile reads the installation's database whatever its flag says")
+ assertEquals(
+ "own database",
+ ProfilesTabState.describeWorkedDataMode(OperatorProfile("p", "DN9APW", false, false)),
+ )
+ }
+
+ @Test
+ fun `a profile never used shows no date`() {
+ val unused = OperatorProfile("p", "DN9APW", false, false)
+
+ assertEquals("", ProfilesTabState.describeLastUsed(unused))
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ShortcutsTabStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ShortcutsTabStateTest.kt
new file mode 100644
index 00000000..ac19a9e3
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/ShortcutsTabStateTest.kt
@@ -0,0 +1,117 @@
+package kst4contest.view.compose.tabs
+
+import kst4contest.model.ChatPreferences
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertSame
+import org.junit.jupiter.api.Assertions.assertTrue
+
+class ShortcutsTabStateTest {
+
+ private class Session {
+ val prefs = ChatPreferences()
+ var shortcutRefreshes = 0
+ var snippetRefreshes = 0
+
+ fun state() = ShortcutsTabState(
+ prefs,
+ refreshShortcutButtons = { shortcutRefreshes++ },
+ refreshTextSnippetContextMenus = { snippetRefreshes++ },
+ )
+ }
+
+ @Test
+ fun `both lists start from the stored rosters`() {
+ val s = Session()
+ s.prefs.getLst_txtShortCutBtnList().setAll(listOf("cq", "tnx"))
+ s.prefs.getLst_txtSnipList().setAll(listOf("qrv 144", "73"))
+
+ val state = s.state()
+
+ assertEquals(listOf("cq", "tnx"), state.shortcuts.texts)
+ assertEquals(listOf("qrv 144", "73"), state.snippets.texts)
+ }
+
+ @Test
+ fun `a new shortcut lands at the top and reaches the roster at once`() {
+ val s = Session()
+ s.prefs.getLst_txtShortCutBtnList().setAll(listOf("alt"))
+ val state = s.state()
+ val rosterBefore = s.prefs.getLst_txtShortCutBtnList()
+
+ state.shortcuts.addAtTop(ShortcutsTabState.NEW_ENTRY_TEMPLATE)
+ state.commitShortcuts()
+
+ assertEquals(
+ listOf(ShortcutsTabState.NEW_ENTRY_TEMPLATE, "alt"),
+ s.prefs.getLst_txtShortCutBtnList().snapshot(),
+ )
+ assertSame(rosterBefore, s.prefs.getLst_txtShortCutBtnList(),
+ "the roster is filled, never replaced: a swap would orphan its listeners")
+ }
+
+ @Test
+ fun `committing the shortcuts rebuilds the shortcut buttons`() {
+ val s = Session()
+ val state = s.state()
+
+ state.shortcuts.addAtTop("cq")
+ state.commitShortcuts()
+
+ assertTrue(s.shortcutRefreshes > 0,
+ "the buttons above the message field are built from this list")
+ assertEquals(0, s.snippetRefreshes, "the snippet menus are a different list")
+ }
+
+ @Test
+ fun `a cleared shortcut disappears from the roster`() {
+ val s = Session()
+ s.prefs.getLst_txtShortCutBtnList().setAll(listOf("cq", "tnx"))
+ val state = s.state()
+
+ state.shortcuts.commitAt(0, " ")
+ state.commitShortcuts()
+
+ assertEquals(listOf("tnx"), s.prefs.getLst_txtShortCutBtnList().snapshot(),
+ "clearing the text was how the operator removed an entry")
+ }
+
+ @Test
+ fun `moving a shortcut reorders the roster`() {
+ val s = Session()
+ s.prefs.getLst_txtShortCutBtnList().setAll(listOf("a", "b"))
+ val state = s.state()
+
+ state.shortcuts.select(0)
+ state.shortcuts.moveSelected(1)
+ state.commitShortcuts()
+
+ assertEquals(listOf("b", "a"), s.prefs.getLst_txtShortCutBtnList().snapshot())
+ }
+
+ @Test
+ fun `a new snippet lands at the top and reaches its own roster`() {
+ val s = Session()
+ s.prefs.getLst_txtSnipList().setAll(listOf("alt"))
+ val state = s.state()
+
+ state.snippets.addAtTop(ShortcutsTabState.NEW_ENTRY_TEMPLATE)
+ state.commitSnippets()
+
+ assertEquals(
+ listOf(ShortcutsTabState.NEW_ENTRY_TEMPLATE, "alt"),
+ s.prefs.getLst_txtSnipList().snapshot(),
+ )
+ assertTrue(s.snippetRefreshes > 0,
+ "the Ctrl+1..Ctrl+0 context menus are built from this list")
+ assertEquals(0, s.shortcutRefreshes)
+ }
+
+ @Test
+ fun `the placeholder text of a new entry is the one the JavaFX button inserted`() {
+ assertEquals(
+ "CHANGE THIS TEXT VIA DOUBLECLICK or remove by deleting all text. Then hit enter key",
+ ShortcutsTabState.NEW_ENTRY_TEMPLATE,
+ )
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StationTabStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StationTabStateTest.kt
new file mode 100644
index 00000000..80f0cf86
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StationTabStateTest.kt
@@ -0,0 +1,98 @@
+package kst4contest.view.compose.tabs
+
+import kst4contest.model.ChatCategory
+import kst4contest.model.ChatPreferences
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertNull
+import org.junit.jupiter.api.Assertions.assertTrue
+
+class StationTabStateTest {
+
+ private class Session {
+ val prefs = ChatPreferences()
+ var mainApplied: ChatCategory? = null
+ var secondApplied: ChatCategory? = null
+ var secondWasApplied = false
+ val state = StationTabState(
+ prefs,
+ { mainApplied = it },
+ { secondApplied = it; secondWasApplied = true },
+ )
+ }
+
+ @Test
+ fun `reading comes straight from the preferences`() {
+ val s = Session()
+ s.prefs.setStn_loginCallSign("DN9APW")
+ s.prefs.setStn_on4kstServersPort(23001)
+
+ assertEquals("DN9APW", s.state.stn_loginCallSign)
+ assertEquals(23001, s.state.stn_on4kstServersPort)
+ }
+
+ @Test
+ fun `writing reaches the preferences at once, without a confirmation step`() {
+ // The JavaFX window had no value-collecting confirmation: each control wrote
+ // straight into ChatPreferences, and "Save settings" only persisted them.
+ val s = Session()
+
+ s.state.stn_loginCallSign = "DO5AMF"
+ s.state.stn_pstRotatorEnabled = true
+ s.state.stn_qtfDefault = 135.0
+
+ assertEquals("DO5AMF", s.prefs.getStn_loginCallSign())
+ assertTrue(s.prefs.isStn_pstRotatorEnabled())
+ assertEquals(135.0, s.prefs.getStn_qtfDefault(), 0.001)
+ }
+
+ @Test
+ fun `a band toggle round trips`() {
+ val s = Session()
+
+ s.state.stn_bandActive144 = true
+ s.state.stn_bandActive432 = false
+
+ assertTrue(s.prefs.isStn_bandActive144())
+ assertEquals(false, s.prefs.isStn_bandActive432())
+ assertTrue(s.state.stn_bandActive144)
+ }
+
+ @Test
+ fun `selecting the main category writes both places it is held`() {
+ val s = Session()
+ val category = ChatCategory(2)
+
+ s.state.selectMainCategory(category)
+
+ assertEquals(2, s.prefs.getLoginChatCategoryMain().categoryNumber,
+ "the stored setting must follow the selection")
+ assertEquals(2, s.mainApplied?.categoryNumber,
+ "so must the running session; the JavaFX window wrote both, and the "
+ + "settings coverage test cannot see the ChatController write")
+ }
+
+ @Test
+ fun `no second category is expressed as null`() {
+ val s = Session()
+
+ s.state.selectSecondCategory(null)
+
+ assertNull(s.prefs.getLoginChatCategorySecond(),
+ "the JavaFX window wrote null for 'no second chat'")
+ assertTrue(s.secondWasApplied, "the running session must learn about it too")
+ }
+
+ @Test
+ fun `the second chat switch lives with the login settings`() {
+ // It moved here from the GUI tab: the JavaFX checkbox stood on the Station tab
+ // next to the category pickers, and it decides what the Connect button does.
+ val prefs = ChatPreferences()
+ val state = StationTabState(prefs, { }, { })
+
+ state.loginToSecondChatEnabled = true
+
+ assertTrue(prefs.isLoginToSecondChatEnabled())
+ assertEquals(true, state.loginToSecondChatEnabled)
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/TrxSynchTabStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/TrxSynchTabStateTest.kt
new file mode 100644
index 00000000..3e9f97a4
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/TrxSynchTabStateTest.kt
@@ -0,0 +1,113 @@
+package kst4contest.view.compose.tabs
+
+import kst4contest.model.ChatPreferences
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+
+class TrxSynchTabStateTest {
+
+ private class Session {
+ val prefs = ChatPreferences()
+ val followerCalls = mutableListOf()
+ val state by lazy { TrxSynchTabState(prefs) { followerCalls.add(it) } }
+ }
+
+ @Test
+ fun `reading comes straight from the preferences`() {
+ val s = Session()
+ s.prefs.setTrxSynch_ucxLogUDPListenerEnabled(true)
+ s.prefs.setLogsynch_wintestQrgSyncEnabled(true)
+ s.prefs.setLogsynch_wintestUsePassQrg(true)
+ s.prefs.setLogsynch_wintestNetworkStationNameOfWintestClient1("STN1")
+
+ assertTrue(s.state.trxSynch_ucxLogUDPListenerEnabled)
+ assertTrue(s.state.logsynch_wintestQrgSyncEnabled)
+ assertTrue(s.state.logsynch_wintestUsePassQrg)
+ assertEquals("STN1", s.state.logsynch_wintestNetworkStationNameOfWintestClient1)
+ }
+
+ @Test
+ fun `writing reaches the preferences at once, without a confirmation step`() {
+ val s = Session()
+
+ s.state.trxSynch_ucxLogUDPListenerEnabled = true
+ s.state.logsynch_wintestQrgSyncEnabled = true
+ s.state.logsynch_wintestUsePassQrg = true
+
+ assertTrue(s.prefs.isTrxSynch_ucxLogUDPListenerEnabled())
+ assertTrue(s.prefs.isLogsynch_wintestQrgSyncEnabled())
+ assertTrue(s.prefs.isLogsynch_wintestUsePassQrg())
+ }
+
+ @Test
+ fun `the Win-Test station name filter is stored trimmed, and an empty filter is allowed`() {
+ // Empty means "accept all" — the JavaFX label said so explicitly.
+ val s = Session()
+
+ s.state.logsynch_wintestNetworkStationNameOfWintestClient1 = " STN1 "
+ assertEquals("STN1", s.prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1())
+
+ s.state.logsynch_wintestNetworkStationNameOfWintestClient1 = " "
+ assertEquals("", s.prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1())
+ }
+
+ @Test
+ fun `the own-QRG follower follows whether any source is enabled`() {
+ val s = Session()
+
+ s.state.trxSynch_ucxLogUDPListenerEnabled = true
+ assertEquals(listOf(true), s.followerCalls)
+
+ s.state.logsynch_wintestQrgSyncEnabled = true
+ assertEquals(listOf(true, true), s.followerCalls)
+
+ // One source off is not all sources off: the follower must stay attached.
+ s.state.trxSynch_ucxLogUDPListenerEnabled = false
+ assertEquals(listOf(true, true, true), s.followerCalls)
+
+ s.state.logsynch_wintestQrgSyncEnabled = false
+ assertEquals(listOf(true, true, true, false), s.followerCalls)
+ }
+
+ @Test
+ fun `an enabled source alone is never presented as a frequency`() {
+ // PROJECT_CONTEXT: automatic QRG updates need an enabled source AND valid
+ // incoming RadioInfo or Win-Test STATUS data. The tab must not let an
+ // operator read "source on" as "my QRG is being kept current".
+ val s = Session()
+
+ s.state.trxSynch_ucxLogUDPListenerEnabled = true
+
+ assertTrue(s.state.anyQrgSourceEnabled)
+ val hint = TrxSynchTabState.QRG_SOURCE_HINT
+ assertTrue(hint.contains("valid", ignoreCase = true) && hint.contains("data", ignoreCase = true),
+ "the hint must say that incoming data is required as well, not just a source: <$hint>")
+ assertTrue(hint.contains("RadioInfo") && hint.contains("STATUS"),
+ "naming both sources tells the operator what data he is waiting for: <$hint>")
+ }
+
+ @Test
+ fun `no enabled source means no automatic QRG at all`() {
+ val s = Session()
+
+ /*
+ * Both sources have to be switched off explicitly: ChatPreferences ships
+ * logsynch_wintestQrgSyncEnabled = true (ChatPreferences.java:258), so a fresh
+ * configuration already has one source enabled.
+ */
+ s.state.logsynch_wintestQrgSyncEnabled = false
+ s.state.trxSynch_ucxLogUDPListenerEnabled = false
+
+ assertFalse(s.state.anyQrgSourceEnabled)
+ }
+
+ @Test
+ fun `a fresh configuration already has the Win-Test source enabled`() {
+ // Pins the shipped default rather than assuming it, which is what made the
+ // test above wrong on the first attempt.
+ assertTrue(Session().state.logsynch_wintestQrgSyncEnabled)
+ assertFalse(Session().state.trxSynch_ucxLogUDPListenerEnabled)
+ }
+}
diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/WorkedDatabaseTabStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/WorkedDatabaseTabStateTest.kt
new file mode 100644
index 00000000..efa3ca39
--- /dev/null
+++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/WorkedDatabaseTabStateTest.kt
@@ -0,0 +1,80 @@
+package kst4contest.view.compose.tabs
+
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertTrue
+
+class WorkedDatabaseTabStateTest {
+
+ private class Recorder(private val resetResult: Int) {
+ var resetCalls = 0
+ var guiRefreshed = false
+ var refreshCalls = 0
+ val state = WorkedDatabaseTabState(
+ resetInDatabase = { resetCalls++; resetResult },
+ refreshGuiLists = { guiRefreshed = true },
+ refreshView = { refreshCalls++ },
+ )
+ }
+
+ @Test
+ fun `a reset asks before it touches anything`() {
+ val r = Recorder(resetResult = 5)
+
+ r.state.requestReset()
+
+ assertTrue(r.state.confirmationPending,
+ "the JavaFX button showed a confirmation dialog first")
+ assertEquals(0, r.resetCalls, "nothing may be reset before the operator confirms")
+ }
+
+ @Test
+ fun `cancelling a reset changes nothing`() {
+ val r = Recorder(resetResult = 5)
+ r.state.requestReset()
+
+ r.state.cancelReset()
+
+ assertFalse(r.state.confirmationPending)
+ assertEquals(0, r.resetCalls)
+ assertFalse(r.guiRefreshed)
+ }
+
+ @Test
+ fun `confirming resets the database and then the lists, in that order`() {
+ val r = Recorder(resetResult = 12)
+ r.state.requestReset()
+
+ r.state.confirmReset()
+
+ assertEquals(1, r.resetCalls)
+ assertTrue(r.guiRefreshed, "the runtime lists must follow the database")
+ assertEquals(12, r.state.lastAffectedLines)
+ assertFalse(r.state.confirmationPending)
+ }
+
+ @Test
+ fun `a failed reset reports an error and leaves the lists alone`() {
+ // resetWorkedDataInDB returns a negative number when it could not confirm a
+ // reset. The JavaFX handler stopped there and did not touch the GUI lists.
+ val r = Recorder(resetResult = -1)
+ r.state.requestReset()
+
+ r.state.confirmReset()
+
+ assertTrue(r.state.resetFailed)
+ assertFalse(r.guiRefreshed,
+ "clearing the runtime lists after a failed database reset would show the "
+ + "operator a state the database does not have")
+ }
+
+ @Test
+ fun `refreshing is a plain action`() {
+ val r = Recorder(resetResult = 0)
+
+ r.state.refresh()
+
+ assertEquals(1, r.refreshCalls)
+ }
+}
diff --git a/app-desktop/src/test/resources/settings-fields.txt b/app-desktop/src/test/resources/settings-fields.txt
new file mode 100644
index 00000000..8f57ac5a
--- /dev/null
+++ b/app-desktop/src/test/resources/settings-fields.txt
@@ -0,0 +1,72 @@
+setAirScout_asBandString
+setAirScout_asClientNameString
+setAirScout_asCommunicationPort
+setAirScout_asServerNameString
+setAirScout_asUDPListenerEnabled
+setAirScout_autoBandSelectionEnabled
+setBcn_beaconsEnabledMainCat
+setBcn_beaconsEnabledSecondCat
+setGUI_darkModeActiveByDefault
+setGuiOptions_defaultFilterNothing
+setGuiOptions_defaultFilterPmToMe
+setGuiOptions_defaultFilterPmToOther
+setGuiOptions_defaultFilterPublicMsgs
+setGuiOptions_showFreshCallHintInBandColumns
+setGuiOptions_showGrossFieldWorkedHintInBandColumns
+setLoginChatCategoryMain
+setLoginChatCategorySecond
+setLoginToSecondChatEnabled
+setLogsynch_fileBasedWkdCallInterpreterEnabled
+setLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly
+setLogsynch_ucxUDPWkdCallListenerEnabled
+setLogsynch_ucxUDPWkdCallListenerPort
+setLogsynch_wintestNetworkBroadcastAddress
+setLogsynch_wintestNetworkListenerEnabled
+setLogsynch_wintestNetworkPort
+setLogsynch_wintestNetworkStationNameOfKST
+setLogsynch_wintestNetworkStationNameOfWintestClient1
+setLogsynch_wintestQrgSyncEnabled
+setLogsynch_wintestUsePassQrg
+setMessageHandling_autoAnswerEnabled
+setMessageHandling_autoAnswerEnabledSecondCat
+setMessageHandling_autoAnswerTextMainCat
+setMessageHandling_autoAnswerTextSecondCat
+setMessageHandling_autoAnswerToQRGRequestEnabled
+setNotify_bandUpgradeHintOnLogEnabled
+setNotify_bandUpgradePriorityBoostEnabled
+setNotify_dxClusterServerEnabled
+setNotify_dxclusterServerPort
+setNotify_DXCSrv_SpottersCallSign
+setNotify_optionalFrequencyPrefix
+setNotify_playCWCallsignsOnRxedPMs
+setNotify_playSimpleSounds
+setNotify_playVoiceCallsignsOnRxedPMs
+setStn_antennaBeamWidthDeg
+setStn_bandActive10G
+setStn_bandActive1240
+setStn_bandActive144
+setStn_bandActive2300
+setStn_bandActive3400
+setStn_bandActive432
+setStn_bandActive50
+setStn_bandActive5600
+setStn_bandActive70
+setStn_loginCallSign
+setStn_loginLocatorMainCat
+setStn_loginNameMainCat
+setStn_loginNameSecondCat
+setStn_loginPassword
+setStn_maxQRBDefault
+setStn_on4kstServersDns
+setStn_on4kstServersPort
+setStn_pathAnalysisDefaultTargetAntennaGainDbi
+setStn_pathAnalysisDefaultTargetTxPowerWatts
+setStn_pathAnalysisDemRootDirectory
+setStn_pathAnalysisOwnAntennaGainDbi
+setStn_pathAnalysisOwnAntennaHeightMeters
+setStn_pathAnalysisOwnTxPowerWatts
+setStn_pstRotatorEnabled
+setStn_pstRotatorHost
+setStn_pstRotatorPort
+setStn_qtfDefault
+setTrxSynch_ucxLogUDPListenerEnabled
diff --git a/app-desktop/src/test/resources/settings-foreign-writes.txt b/app-desktop/src/test/resources/settings-foreign-writes.txt
new file mode 100644
index 00000000..898ff93d
--- /dev/null
+++ b/app-desktop/src/test/resources/settings-foreign-writes.txt
@@ -0,0 +1,5 @@
+setCallSign
+setChatCategoryMain
+setChatCategorySecondChat
+setFrequency
+setQra
diff --git a/build.gradle.kts b/build.gradle.kts
index ed7ff064..d4e76fa5 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -9,6 +9,9 @@ allprojects {
repositories {
mavenCentral()
+ // Compose Multiplatform pulls its androidx.lifecycle and androidx.annotation
+ // artifacts from Google's repository; they are not on Maven Central.
+ google()
}
}
diff --git a/core/src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java b/core/src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java
index 85956153..100891c7 100644
--- a/core/src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java
+++ b/core/src/main/java/kst4contest/view/map/MapCallsignRawSnapshotBuilder.java
@@ -92,8 +92,14 @@ public final class MapCallsignRawSnapshotBuilder {
boolean worked = variants.stream().anyMatch(this::isWorkedAtAnyBand);
boolean selected = callSignRaw.equals(selectedCallsignRaw);
- double qrbKm = representative.getQrb() != null ? representative.getQrb() : 0.0;
- double qtfDeg = representative.getQTFdirection() != null ? representative.getQTFdirection() : 0.0;
+ /*
+ * NaN and not 0.0 when the value is absent. Zero is a legitimate reading —
+ * a station at the operator's own locator is 0 km away — so collapsing null
+ * onto it destroys the difference between "right here" and "not known", and
+ * every consumer then shows a distance that was never measured.
+ */
+ double qrbKm = representative.getQrb() != null ? representative.getQrb() : Double.NaN;
+ double qtfDeg = representative.getQTFdirection() != null ? representative.getQTFdirection() : Double.NaN;
int reachableAirplanes = variants.stream()
.map(ChatMember::getAirPlaneReflectInfo)
diff --git a/core/src/main/java/kst4contest/view/map/MapMarkerCluster.java b/core/src/main/java/kst4contest/view/map/MapMarkerCluster.java
new file mode 100644
index 00000000..fd4a2088
--- /dev/null
+++ b/core/src/main/java/kst4contest/view/map/MapMarkerCluster.java
@@ -0,0 +1,53 @@
+package kst4contest.view.map;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+public final class MapMarkerCluster {
+ private final List markers = new ArrayList<>();
+ private double sumLon = 0.0;
+ private double sumLat = 0.0;
+
+ public MapMarkerCluster(MapCallsignRawSnapshot first) {
+ add(first);
+ }
+
+ public void add(MapCallsignRawSnapshot marker) {
+ markers.add(marker);
+ sumLon += marker.longitudeDeg();
+ sumLat += marker.latitudeDeg();
+ }
+
+ public double getCenterLon() {
+ return sumLon / markers.size();
+ }
+
+ public double getCenterLat() {
+ return sumLat / markers.size();
+ }
+
+ public double getCenterX(int zoom) {
+ return WebMercatorProjection.lonToX(getCenterLon(), zoom);
+ }
+
+ public double getCenterY(int zoom) {
+ return WebMercatorProjection.latToY(getCenterLat(), zoom);
+ }
+
+ public List getMarkers() {
+ return Collections.unmodifiableList(markers);
+ }
+
+ public int size() {
+ return markers.size();
+ }
+
+ public boolean isSingle() {
+ return markers.size() == 1;
+ }
+
+ public MapCallsignRawSnapshot getSingle() {
+ return markers.get(0);
+ }
+}
diff --git a/core/src/main/java/kst4contest/view/map/MapMarkerClusterTooltip.java b/core/src/main/java/kst4contest/view/map/MapMarkerClusterTooltip.java
new file mode 100644
index 00000000..401f32f5
--- /dev/null
+++ b/core/src/main/java/kst4contest/view/map/MapMarkerClusterTooltip.java
@@ -0,0 +1,51 @@
+package kst4contest.view.map;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * What hovering a cluster bubble says.
+ *
+ * The count first, because that is the question the bubble raises. Then the callsigns,
+ * alphabetically and capped, because a cell at low zoom can hold a hundred of them and
+ * a tooltip taller than the window hides the map it was meant to explain. Then the hint
+ * that clicking zooms in, without which a bubble looks like a dead end.
+ *
+ * Kept as the Leaflet tooltip had it (buildClusterTooltipHtml); only the line breaks
+ * differ, because this one is drawn on a canvas rather than written into HTML.
+ */
+public final class MapMarkerClusterTooltip {
+
+ /** Twenty, as the Leaflet tooltip did. Beyond that a list stops being readable. */
+ public static final int MAX_PREVIEWED_STATIONS = 20;
+
+ private MapMarkerClusterTooltip() {
+ }
+
+ public static String build(MapMarkerCluster cluster) {
+ List callsigns = new ArrayList<>();
+ for (MapCallsignRawSnapshot marker : cluster.getMarkers()) {
+ String label = marker.markerLabel();
+ if (label != null && !label.isBlank()) {
+ callsigns.add(label.trim());
+ }
+ }
+ Collections.sort(callsigns);
+
+ StringBuilder text = new StringBuilder();
+ text.append(cluster.size()).append(" stations");
+
+ int previewed = Math.min(MAX_PREVIEWED_STATIONS, callsigns.size());
+ for (int i = 0; i < previewed; i++) {
+ text.append('\n').append(callsigns.get(i));
+ }
+
+ int remaining = callsigns.size() - previewed;
+ if (remaining > 0) {
+ text.append('\n').append('+').append(remaining).append(" more");
+ }
+
+ return text.append('\n').append("Click to zoom in").toString();
+ }
+}
diff --git a/core/src/main/java/kst4contest/view/map/MapViewport.java b/core/src/main/java/kst4contest/view/map/MapViewport.java
new file mode 100644
index 00000000..0a9bdb56
--- /dev/null
+++ b/core/src/main/java/kst4contest/view/map/MapViewport.java
@@ -0,0 +1,230 @@
+package kst4contest.view.map;
+
+/**
+ * What the map currently shows, and where a position lands on the canvas.
+ *
+ * Everything the map draws except the tiles is drawn in screen pixels: a station dot is
+ * 12 px at every zoom, a label is 12 px at every zoom, a grid line is 1.4 px at every
+ * zoom. Leaflet behaved that way because its overlays live in the DOM above a scaled
+ * tile layer. The first Compose port instead computed positions at the integer zoom and
+ * then scaled the whole canvas by the fractional part, which made every drawn size a
+ * function of that fraction and forced the drawing code to divide each size back out
+ * again. Sizes were then right at one zoom and wrong at the next.
+ *
+ * This class folds the fraction into the projection instead. {@link #screenX(double)}
+ * and {@link #screenY(double)} answer in canvas pixels, so the drawing code needs no
+ * scaling and no compensation. Only the tiles are scaled, by {@link #tileScale()},
+ * because tiles exist at integer levels only.
+ *
+ * Immutable: a viewport describes one frame. Panning and zooming produce a new one.
+ */
+public final class MapViewport {
+
+ /**
+ * The range the tile source serves. Leaflet was configured for exactly this
+ * ({@code KST_MIN_ZOOM}/{@code KST_MAX_ZOOM}); outside it the requested tiles do
+ * not exist.
+ */
+ public static final double MIN_ZOOM = 3.0;
+ public static final double MAX_ZOOM = 18.0;
+
+ private static final double TILE_SIZE_PX = 256.0;
+
+ private final double centerLon;
+ private final double centerLat;
+ private final double zoom;
+ private final double widthPx;
+ private final double heightPx;
+
+ private final int tileZoom;
+ private final double tileScale;
+ private final double centerWorldX;
+ private final double centerWorldY;
+
+ public MapViewport(double centerLon,
+ double centerLat,
+ double zoom,
+ double widthPx,
+ double heightPx) {
+
+ this.centerLon = normaliseLongitude(centerLon);
+ this.centerLat = centerLat;
+ this.zoom = clamp(zoom, MIN_ZOOM, MAX_ZOOM);
+ this.widthPx = Math.max(0.0, widthPx);
+ this.heightPx = Math.max(0.0, heightPx);
+
+ this.tileZoom = (int) Math.floor(this.zoom);
+ this.tileScale = Math.pow(2.0, this.zoom - this.tileZoom);
+ this.centerWorldX = WebMercatorProjection.lonToX(this.centerLon, this.tileZoom);
+ this.centerWorldY = WebMercatorProjection.latToY(centerLat, this.tileZoom);
+ }
+
+ public double centerLon() {
+ return centerLon;
+ }
+
+ public double centerLat() {
+ return centerLat;
+ }
+
+ /** The zoom actually in force, after clamping to the served range. */
+ public double zoom() {
+ return zoom;
+ }
+
+ public double widthPx() {
+ return widthPx;
+ }
+
+ public double heightPx() {
+ return heightPx;
+ }
+
+ /** The integer level the tiles come from. */
+ public int tileZoom() {
+ return tileZoom;
+ }
+
+ /** What a tile of that level must be scaled by to match the fractional zoom. */
+ public double tileScale() {
+ return tileScale;
+ }
+
+ /** The on-screen size of one tile, in canvas pixels. */
+ public double tileSizePx() {
+ return TILE_SIZE_PX * tileScale;
+ }
+
+ public double screenX(double lon) {
+ return widthPx / 2.0 + (WebMercatorProjection.lonToX(lon, tileZoom) - centerWorldX) * tileScale;
+ }
+
+ public double screenY(double lat) {
+ return heightPx / 2.0 + (WebMercatorProjection.latToY(lat, tileZoom) - centerWorldY) * tileScale;
+ }
+
+ /** Where a world pixel of {@link #tileZoom()} lands on the canvas. */
+ public double screenXForWorldX(double worldX) {
+ return widthPx / 2.0 + (worldX - centerWorldX) * tileScale;
+ }
+
+ public double screenYForWorldY(double worldY) {
+ return heightPx / 2.0 + (worldY - centerWorldY) * tileScale;
+ }
+
+ public double lonAt(double screenX) {
+ return WebMercatorProjection.xToLon(worldXAt(screenX), tileZoom);
+ }
+
+ public double latAt(double screenY) {
+ return WebMercatorProjection.yToLat(worldYAt(screenY), tileZoom);
+ }
+
+ public double worldXAt(double screenX) {
+ return centerWorldX + (screenX - widthPx / 2.0) / tileScale;
+ }
+
+ public double worldYAt(double screenY) {
+ return centerWorldY + (screenY - heightPx / 2.0) / tileScale;
+ }
+
+ public double westLon() {
+ return lonAt(0.0);
+ }
+
+ public double eastLon() {
+ return lonAt(widthPx);
+ }
+
+ /** North is the top of the canvas, which is y = 0. */
+ public double northLat() {
+ return latAt(0.0);
+ }
+
+ public double southLat() {
+ return latAt(heightPx);
+ }
+
+ /**
+ * The same view moved by a drag, in canvas pixels.
+ *
+ * A drag of n pixels moves what was under the pointer by exactly n pixels, which is
+ * why the delta is divided by the tile scale rather than applied to the world
+ * coordinates directly.
+ */
+ public MapViewport pannedBy(double deltaX, double deltaY) {
+ return centredOnWorld(
+ centerWorldX - deltaX / tileScale,
+ centerWorldY - deltaY / tileScale,
+ zoom
+ );
+ }
+
+ /**
+ * The same view at a different zoom, with the position under
+ * {@code anchorX}/{@code anchorY} left where it is.
+ *
+ * Zooming about the pointer rather than the centre is what makes a wheel zoom feel
+ * like a map. The anchor's position is read before the zoom changes and the new
+ * centre is derived from it afterwards, because the world coordinates of a position
+ * change with the tile level.
+ */
+ public MapViewport zoomedTo(double newZoom, double anchorX, double anchorY) {
+ double anchorLon = lonAt(anchorX);
+ double anchorLat = latAt(anchorY);
+
+ double clampedZoom = clamp(newZoom, MIN_ZOOM, MAX_ZOOM);
+ int newTileZoom = (int) Math.floor(clampedZoom);
+ double newTileScale = Math.pow(2.0, clampedZoom - newTileZoom);
+
+ double anchorWorldX = WebMercatorProjection.lonToX(anchorLon, newTileZoom);
+ double anchorWorldY = WebMercatorProjection.latToY(anchorLat, newTileZoom);
+
+ return new MapViewport(
+ WebMercatorProjection.xToLon(anchorWorldX - (anchorX - widthPx / 2.0) / newTileScale, newTileZoom),
+ WebMercatorProjection.yToLat(anchorWorldY - (anchorY - heightPx / 2.0) / newTileScale, newTileZoom),
+ clampedZoom,
+ widthPx,
+ heightPx
+ );
+ }
+
+ /** The same view on a canvas of a different size. */
+ public MapViewport resizedTo(double newWidthPx, double newHeightPx) {
+ return new MapViewport(centerLon, centerLat, zoom, newWidthPx, newHeightPx);
+ }
+
+ private MapViewport centredOnWorld(double worldX, double worldY, double atZoom) {
+ return new MapViewport(
+ WebMercatorProjection.xToLon(worldX, tileZoom),
+ WebMercatorProjection.yToLat(worldY, tileZoom),
+ atZoom,
+ widthPx,
+ heightPx
+ );
+ }
+
+ /**
+ * Brings a longitude back into [-180, 180).
+ *
+ * Panning east is unbounded arithmetic. The tile indices wrap, so the basemap keeps
+ * drawing, but nothing else does: a centre at longitude 262 puts a station at -170
+ * thousands of pixels off the canvas and hands the grid a span it clamps to
+ * nothing. The stations and the grid then disappear while the map underneath looks
+ * perfectly fine.
+ */
+ private static double normaliseLongitude(double lon) {
+ if (!Double.isFinite(lon)) {
+ return 0.0;
+ }
+ double wrapped = (lon + 180.0) % 360.0;
+ if (wrapped < 0.0) {
+ wrapped += 360.0;
+ }
+ return wrapped - 180.0;
+ }
+
+ private static double clamp(double value, double min, double max) {
+ return Math.max(min, Math.min(max, value));
+ }
+}
diff --git a/core/src/main/java/kst4contest/view/map/StationMapClusterer.java b/core/src/main/java/kst4contest/view/map/StationMapClusterer.java
new file mode 100644
index 00000000..6b755237
--- /dev/null
+++ b/core/src/main/java/kst4contest/view/map/StationMapClusterer.java
@@ -0,0 +1,160 @@
+package kst4contest.view.map;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Groups stations that sit on top of each other on screen.
+ *
+ * The rules come from the Leaflet map and are kept as they were, because an operator
+ * has learned them: grouping is by screen cell rather than by distance, a cell holds a
+ * bubble only from three stations upwards, grouping stops once the map is zoomed in far
+ * enough to show everything, and two kinds of station are never hidden in a bubble.
+ *
+ * Screen based and not locator based on purpose. The problem being solved is too many
+ * labels in one part of the window, and that is a question about pixels.
+ *
+ * The first Compose port replaced this with nearest-neighbour grouping at a fixed 50 px
+ * from two stations upwards and never switched it off, which put a "2" bubble over
+ * most of the map.
+ */
+public final class StationMapClusterer {
+
+ /**
+ * Two nearby stations are still readable and should stay individually clickable, so
+ * a bubble starts at three.
+ */
+ public static final int MIN_STATIONS_PER_CLUSTER = 3;
+
+ /** From this zoom upwards the map shows every station. */
+ public static final double DISABLE_AT_ZOOM = 8.0;
+
+ private static final double CELL_SIZE_HIGH_ZOOM_PX = 55.0;
+ private static final double CELL_SIZE_MEDIUM_ZOOM_PX = 70.0;
+ private static final double CELL_SIZE_LOW_ZOOM_PX = 95.0;
+ private static final double CELL_SIZE_VERY_LOW_ZOOM_PX = 125.0;
+
+ private StationMapClusterer() {
+ }
+
+ /**
+ * What the map should draw: bubbles for the crowded cells, single markers for
+ * everything else.
+ *
+ * @param clusters stations grouped into a bubble; never fewer than
+ * {@link #MIN_STATIONS_PER_CLUSTER} in one
+ * @param individual stations to draw as themselves, with their label
+ */
+ public record Result(List clusters, List individual) {
+
+ public Result {
+ clusters = List.copyOf(clusters);
+ individual = List.copyOf(individual);
+ }
+ }
+
+ /**
+ * @param viewport decides both the cell size, through its zoom, and which screen
+ * cell a station falls into
+ * @param clusteringEnabled the operator's "Group nearby stations" setting
+ */
+ public static Result cluster(List markers,
+ MapViewport viewport,
+ boolean clusteringEnabled) {
+
+ if (markers == null || markers.isEmpty()) {
+ return new Result(List.of(), List.of());
+ }
+
+ List positioned = new ArrayList<>();
+ for (MapCallsignRawSnapshot marker : markers) {
+ if (marker != null && marker.hasUsablePosition()) {
+ positioned.add(marker);
+ }
+ }
+
+ if (!clusteringEnabled || viewport.zoom() >= DISABLE_AT_ZOOM) {
+ return new Result(List.of(), positioned);
+ }
+
+ double cellSizePx = cellSizePxForZoom(viewport.zoom());
+
+ List individual = new ArrayList<>();
+ Map> buckets = new LinkedHashMap<>();
+
+ for (MapCallsignRawSnapshot marker : positioned) {
+ if (mustStayVisible(marker)) {
+ individual.add(marker);
+ continue;
+ }
+
+ long cellX = (long) Math.floor(viewport.screenX(marker.longitudeDeg()) / cellSizePx);
+ long cellY = (long) Math.floor(viewport.screenY(marker.latitudeDeg()) / cellSizePx);
+
+ buckets.computeIfAbsent(cellKey(cellX, cellY), key -> new ArrayList<>()).add(marker);
+ }
+
+ List clusters = new ArrayList<>();
+ for (List