From a4e10300e77d5502dbb51fd59c7a54efc639b1e3 Mon Sep 17 00:00:00 2001 From: Praktimarc <61456874+praktimarc@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:10:36 +0200 Subject: [PATCH] Fix Compose UI regressions in KST4Contest 1.50 (#147) * test: make stylesheet tests platform-independent * fix: address Compose UI regressions * fix: complete Compose UI workflow corrections * feat: improve manual QTF heading control --- .../src/main/i18n/strings_de.properties | 39 +- .../src/main/i18n/strings_en.properties | 41 +- .../view/Kst4ContestApplication.java | 348 ++++++---- .../kst4contest/view/compose/ChatInputRow.kt | 75 +-- .../kst4contest/view/compose/ConnectionBar.kt | 25 +- .../view/compose/DataTableState.kt | 181 ++++-- .../kst4contest/view/compose/DataTableView.kt | 185 ++++-- .../kst4contest/view/compose/Density.kt | 34 +- .../kst4contest/view/compose/FormControls.kt | 384 +++++++++-- .../kst4contest/view/compose/MainWindow.kt | 50 +- .../view/compose/MainWindowHost.kt | 9 +- .../view/compose/MainWindowState.kt | 56 +- .../compose/ManualQtfHeadingSynchronizer.kt | 19 + .../view/compose/MapReachabilityState.kt | 83 +++ .../kst4contest/view/compose/MessageTabs.kt | 61 +- .../view/compose/NewJoinedState.kt | 100 +++ .../view/compose/ReachabilityState.kt | 58 ++ .../SelectedStationMessagePredicate.kt | 60 ++ .../view/compose/SelectedStationPanel.kt | 594 ++++++++++++++---- .../view/compose/SelectedStationState.kt | 71 ++- .../kst4contest/view/compose/SettingsTabs.kt | 16 +- .../view/compose/SkedEditorState.kt | 50 ++ .../kst4contest/view/compose/SplitterPane.kt | 20 +- .../kst4contest/view/compose/SplitterState.kt | 77 ++- .../view/compose/StationColumns.kt | 9 +- .../view/compose/StationContextMenu.kt | 314 +++++++++ .../view/compose/StationContextMenuState.kt | 59 ++ .../view/compose/StationFilterBar.kt | 492 +++++++-------- .../view/compose/StationFilterState.kt | 4 + .../view/compose/StationSelectionState.kt | 135 ++++ .../kst4contest/view/compose/TimelineState.kt | 50 +- .../kst4contest/view/compose/TimelineView.kt | 72 ++- .../view/compose/map/ComposeStationMap.kt | 92 ++- .../view/compose/map/StationMapWindow.kt | 127 +++- .../view/compose/tabs/StationTab.kt | 28 +- .../view/compose/tabs/StationTabState.kt | 8 +- .../view/RosterListBindingTest.java | 56 ++ .../view/SelectedStationMessageFlowTest.java | 180 ++++++ .../compose/ChatInputRowSo2rScreenTest.kt | 53 ++ .../view/compose/CompactControlsScreenTest.kt | 149 +++++ .../CompactControlsVisualRenderingTest.kt | 114 ++++ .../view/compose/ConnectionBarScreenTest.kt | 103 +++ .../view/compose/DataTableStateTest.kt | 109 ++++ .../GuiStartupDesignPersistenceTest.kt | 57 ++ .../view/compose/MainWindowStateTest.kt | 9 + .../ManualQtfHeadingSynchronizerTest.kt | 55 ++ .../view/compose/MapReachabilityStateTest.kt | 66 ++ .../view/compose/MessageTabsScreenTest.kt | 118 ++++ .../view/compose/NewJoinedBarScreenTest.kt | 126 ++++ .../view/compose/NewJoinedStateTest.kt | 115 ++++ .../view/compose/PaletteStoreFactoryTest.kt | 4 +- .../view/compose/ReachabilityStateTest.kt | 63 ++ .../SelectedStationMessagePredicateTest.kt | 155 +++++ ...ctedStationMessageUiVisualRenderingTest.kt | 158 +++++ .../compose/SelectedStationPanelScreenTest.kt | 431 +++++++++++++ .../view/compose/SelectedStationStateTest.kt | 15 + .../view/compose/SkedEditorStateTest.kt | 82 +++ .../view/compose/SplitterStateTest.kt | 51 ++ .../compose/StationContextMenuStateTest.kt | 90 +++ .../compose/StationFilterBarScreenTest.kt | 192 +++++- .../view/compose/StationListScreenTest.kt | 100 ++- .../view/compose/StationSelectionStateTest.kt | 124 ++++ .../StationWorkspaceVisualRenderingTest.kt | 157 +++++ .../view/compose/TimelineStateTest.kt | 41 +- .../view/compose/TimelineViewScreenTest.kt | 34 + .../map/ComposeStationMapDrawingTest.kt | 52 +- .../compose/map/StationMapWindowScreenTest.kt | 137 +++- .../map/WorkedGrossFieldOverlayTest.kt | 25 + .../compose/tabs/DraftedSettingsTableTest.kt | 3 + .../view/compose/tabs/SettingsTabScreens.kt | 7 +- .../view/compose/tabs/StationTabScreenTest.kt | 46 +- .../view/compose/tabs/StationTabStateTest.kt | 72 ++- .../view/compose/tabs/StylesheetExportTest.kt | 14 +- .../controller/ChatController.java | 141 ++++- .../kst4contest/controller/LoggedQsoBand.java | 21 + .../MessageBusManagementThread.java | 42 +- .../controller/ReadUDPByWintestThread.java | 25 +- .../controller/ReadUDPbyUCXMessageThread.java | 122 +--- .../controller/WorkedGrossFieldCache.java | 18 +- .../kst4contest/model/ChatPreferences.java | 74 ++- .../kst4contest/model/RecentChatJoin.java | 48 ++ .../utils/ApplicationFileLogging.java | 200 +++++- .../utils/ApplicationFileUtils.java | 15 + .../view/map/MapCallsignRawSnapshot.java | 30 +- .../view/map/MapMarkerClusterTooltip.java | 2 +- .../main/resources/praktiKSTpreferences.xml | 3 +- .../ChatControllerNotQrvPersistenceTest.java | 40 ++ .../ChatControllerRecentJoinTest.java | 72 +++ .../ChatControllerWorkedActionTest.java | 66 ++ .../controller/ExternalLoggedQsoTest.java | 10 + .../ExternalLoggerListenerValidationTest.java | 4 +- ...ssageBusManagementThreadChatFrameTest.java | 97 ++- .../MessageHistoryRecorderTest.java | 19 + ...ReadUDPbyUCXMessageThreadLoopbackTest.java | 39 +- .../controller/WorkedGrossFieldCacheTest.java | 25 + .../model/ChatPreferencesQtfHeadingTest.java | 195 ++++++ .../model/ChatPreferencesRoundTripTest.java | 4 + .../test/ChatPreferencesDebugModeTest.java | 2 +- .../ChatPreferencesLayoutPersistenceTest.java | 6 +- ...tPreferencesStationMapAlwaysOnTopTest.java | 82 +++ ...atPreferencesStationMapClusteringTest.java | 2 +- .../utils/ApplicationFileLoggingTest.java | 87 +++ .../utils/ApplicationFileUtilsTest.java | 19 + .../map/MapCallsignRawSnapshotLabelTest.java | 37 ++ .../view/map/MapMarkerClusterTooltipTest.java | 7 +- docs/PROJECT_CONTEXT.md | 34 +- github_docs/de-Benutzeroberflaeche.md | 76 ++- github_docs/de-Changelog.md | 8 + github_docs/de-Home.md | 6 +- github_docs/de-Konfiguration.md | 18 +- github_docs/en-Changelog.md | 8 + github_docs/en-Configuration.md | 18 +- github_docs/en-Home.md | 6 +- github_docs/en-User-Interface.md | 76 ++- 114 files changed, 8065 insertions(+), 1128 deletions(-) create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/ManualQtfHeadingSynchronizer.kt create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/MapReachabilityState.kt create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/NewJoinedState.kt create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/ReachabilityState.kt create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationMessagePredicate.kt create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/SkedEditorState.kt create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/StationContextMenu.kt create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/StationContextMenuState.kt create mode 100644 app-desktop/src/main/kotlin/kst4contest/view/compose/StationSelectionState.kt create mode 100644 app-desktop/src/test/java/kst4contest/view/SelectedStationMessageFlowTest.java create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/CompactControlsScreenTest.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/CompactControlsVisualRenderingTest.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/ConnectionBarScreenTest.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/GuiStartupDesignPersistenceTest.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/ManualQtfHeadingSynchronizerTest.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/MapReachabilityStateTest.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/MessageTabsScreenTest.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/NewJoinedBarScreenTest.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/NewJoinedStateTest.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/ReachabilityStateTest.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/SelectedStationMessagePredicateTest.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/SelectedStationMessageUiVisualRenderingTest.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/SelectedStationPanelScreenTest.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/SkedEditorStateTest.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/StationContextMenuStateTest.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/StationSelectionStateTest.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/StationWorkspaceVisualRenderingTest.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/TimelineViewScreenTest.kt create mode 100644 app-desktop/src/test/kotlin/kst4contest/view/compose/map/WorkedGrossFieldOverlayTest.kt create mode 100644 core/src/main/java/kst4contest/model/RecentChatJoin.java create mode 100644 core/src/test/java/kst4contest/controller/ChatControllerNotQrvPersistenceTest.java create mode 100644 core/src/test/java/kst4contest/controller/ChatControllerRecentJoinTest.java create mode 100644 core/src/test/java/kst4contest/controller/ChatControllerWorkedActionTest.java create mode 100644 core/src/test/java/kst4contest/controller/WorkedGrossFieldCacheTest.java create mode 100644 core/src/test/java/kst4contest/model/ChatPreferencesQtfHeadingTest.java create mode 100644 core/src/test/java/kst4contest/test/ChatPreferencesStationMapAlwaysOnTopTest.java create mode 100644 core/src/test/java/kst4contest/utils/ApplicationFileUtilsTest.java create mode 100644 core/src/test/java/kst4contest/view/map/MapCallsignRawSnapshotLabelTest.java diff --git a/app-desktop/src/main/i18n/strings_de.properties b/app-desktop/src/main/i18n/strings_de.properties index 9037983a..cfa08fc4 100644 --- a/app-desktop/src/main/i18n/strings_de.properties +++ b/app-desktop/src/main/i18n/strings_de.properties @@ -122,6 +122,9 @@ station.password=Passwort station.pathSection=Vorgaben für die Streckenanalyse station.qtf=QTF station.rotatorEnabled=Rotorabgleich aktiv +station.manualAntennaHeadingSection=Manuelle Antennenrichtung +station.manualAntennaHeadingRotatorHint=Nicht verfügbar, solange PSTRotator 1 aktiviert ist +station.useQtfFilterDirectionAsAntennaHeading=QTF-Filterrichtung als eigene Antennenrichtung übernehmen station.rotatorHost=Rotor-Host station.rotatorPort=Rotor-Port station.rotatorSection=PSTRotator @@ -256,18 +259,26 @@ filter.onlyNewGrids=Nur neue Felder filter.gridColour=Feldfarbe filter.newBands=Neue Bänder filter.tropo=Tropo >=0dB +filter.hide=Ausblenden +filter.show=Anzeigen +filter.reachability=Erreichbarkeit +filter.calcSelected=Auswahl berechnen +table.empty=Keine Einträge selected.none=Keine Station gewählt. selected.priority=Priorität: +selected.newJoined=Neu hinzugekommen: selected.more=mehr selected.showMessages=Nachrichten zeigen selected.createSked=Sked anlegen -selected.sked=Sked -selected.skedInMinutes=Sked in (Min.) +selected.sked=Sked alert +selected.skedIn=Sked in +selected.skedMinutesAt=Min., auf +selected.skedModeLink=, Mode +selected.minutes=Min. selected.skedFail=Sked fehlgeschlagen selected.remindPmIn=Erinnerungs-PM in -selected.reminderOffsets=Erinnerungsabstände selected.resetFail=Zurücksetzen fehlgeschlagen -selected.tagNotQrv=Als NOT QRV markieren +selected.tagNotQrv={0} an folgenden Bändern als NOT QRV markieren: selected.band=Band selected.mode=Mode selected.lookUpAndShow=Nachschlagen und zeigen @@ -280,6 +291,12 @@ selected.lastActivity=Letzte Aktivität: {0} (vor {1} Min.) candidates.title=Stationen mit höchster Priorität candidates.none=Noch keine Kandidaten. candidates.doubleClick=Doppelklick auf einen Kandidaten wählt ihn aus. +timeline.inBeam1=Im Strahl 1 +timeline.inBeam2=Im Strahl 2 +timeline.priority1=Priorität 1 +timeline.priority2=Priorität 2 +timeline.skeds=Skeds +timeline.minuteSuffix=min update.title=Aktualisierungsinformationen update.available=Aktualisierung verfügbar! update.installedVersion=Installierte Version: @@ -294,7 +311,9 @@ update.versions=Versionen map.title=Stationskarte map.tab=Karte map.resetView=Ansicht zurücksetzen -map.groupNearby=Nahe Stationen gruppieren +map.groupNearby=Gruppierung +map.groupNearbyTooltip=Nahe beieinanderliegende Stationen auf der Karte gruppieren. +map.alwaysOnTop=Immer im Vordergrund map.triggerClusterSpot=Cluster-Spot auslösen map.showPathAnalysis=Streckenanalyse zeigen map.hidePathAnalysis=Streckenanalyse ausblenden @@ -419,6 +438,16 @@ dialog.apply=Übernehmen chat.clear=leeren selected.showOnMap=🗺 Auf der Karte zeigen selected.notQrvAll=alle +context.messageSnippets=Textbausteine +context.createSked=Sked erstellen… +context.tagNotQrv=Als NOT QRV markieren +context.markWorked=Als gearbeitet markieren +context.calculateReachability=Erreichbarkeit berechnen +context.chooseStation=Station auswählen +context.back=Zurück +context.category=Kategorie {0} +context.skedTitle=Sked für {0} erstellen +context.cancel=Abbrechen column.category=Kategorie column.sender=Absender column.receiver=Empfänger diff --git a/app-desktop/src/main/i18n/strings_en.properties b/app-desktop/src/main/i18n/strings_en.properties index 4a445720..18491582 100644 --- a/app-desktop/src/main/i18n/strings_en.properties +++ b/app-desktop/src/main/i18n/strings_en.properties @@ -131,6 +131,9 @@ station.password=Password station.pathSection=Path analysis defaults station.qtf=QTF station.rotatorEnabled=Rotator sync enabled +station.manualAntennaHeadingSection=Manual antenna heading +station.manualAntennaHeadingRotatorHint=Unavailable while PSTRotator 1 is enabled +station.useQtfFilterDirectionAsAntennaHeading=Use QTF filter direction as antenna heading station.rotatorHost=Rotator host station.rotatorPort=Rotator port station.rotatorSection=PSTRotator @@ -265,18 +268,26 @@ filter.onlyNewGrids=Only new grids filter.gridColour=Grid color filter.newBands=New bands filter.tropo=Tropo >=0dB +filter.hide=Hide +filter.show=Show +filter.reachability=Reachability +filter.calcSelected=Calc selected +table.empty=No entries selected.none=No station selected. selected.priority=Priority: +selected.newJoined=New joined: selected.more=more selected.showMessages=Show messages selected.createSked=Create sked -selected.sked=Sked -selected.skedInMinutes=Sked in (min) +selected.sked=Sked alert +selected.skedIn=Sked in +selected.skedMinutesAt=min, at +selected.skedModeLink=, mode +selected.minutes=min selected.skedFail=Sked fail -selected.remindPmIn=Remind-PM in -selected.reminderOffsets=Reminder offsets +selected.remindPmIn=Remind pm in selected.resetFail=Reset fail -selected.tagNotQrv=Tag not QRV +selected.tagNotQrv=Tag {0} not QRV at bands: selected.band=Band selected.mode=Mode selected.lookUpAndShow=Look up and show @@ -289,6 +300,12 @@ selected.lastActivity=Last activity: {0} ({1} min ago) candidates.title=Top priority candidates candidates.none=No candidates yet. candidates.doubleClick=Double-click a candidate to select it. +timeline.inBeam1=In beam 1 +timeline.inBeam2=In beam 2 +timeline.priority1=Priority 1 +timeline.priority2=Priority 2 +timeline.skeds=Skeds +timeline.minuteSuffix=min update.title=Update information update.available=Update available! update.installedVersion=Installed version: @@ -303,7 +320,9 @@ update.versions=Versions map.title=Station Map map.tab=Map map.resetView=Reset view -map.groupNearby=Group nearby stations +map.groupNearby=Grouping +map.groupNearbyTooltip=Group stations that are close together on the map. +map.alwaysOnTop=Always on top map.triggerClusterSpot=Trigger cluster spot map.showPathAnalysis=Show path analysis map.hidePathAnalysis=Hide path analysis @@ -428,6 +447,16 @@ dialog.apply=Apply chat.clear=clear selected.showOnMap=🗺 Show on map selected.notQrvAll=all +context.messageSnippets=Message snippets +context.createSked=Create sked… +context.tagNotQrv=Tag NOT QRV +context.markWorked=Mark as worked +context.calculateReachability=Calculate reachability +context.chooseStation=Choose station +context.back=Back +context.category=Category {0} +context.skedTitle=Create sked for {0} +context.cancel=Cancel column.category=Category column.sender=Sender column.receiver=Receiver diff --git a/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java b/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java index 5d83d753..2d7f21b9 100644 --- a/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +++ b/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java @@ -10,6 +10,7 @@ import kst4contest.view.compose.DataTableState; import kst4contest.view.compose.MonitorColumns; import kst4contest.view.compose.MonitorWindow; import kst4contest.view.compose.RowKeys; +import kst4contest.view.compose.ReachabilityState; import kst4contest.view.compose.BlinkingNotice; import kst4contest.view.compose.ThreadStatusButtons; import kst4contest.view.compose.StationColumns; @@ -18,9 +19,16 @@ import kst4contest.view.compose.DirectedMessageColumns; import kst4contest.view.compose.PublicMessageColumns; import kst4contest.view.compose.SelectedMessageFilter; import kst4contest.view.compose.SelectedStationMessageColumns; +import kst4contest.view.compose.SelectedStationMessagePredicate; import kst4contest.view.compose.SelectedStationState; +import kst4contest.view.compose.DirectedMessageStationTarget; +import kst4contest.view.compose.StationIdentity; +import kst4contest.view.compose.StationSelectionSource; +import kst4contest.view.compose.StationSelectionState; import kst4contest.view.compose.TopPriorityCandidatesWindow; import kst4contest.view.compose.TopPriorityState; +import kst4contest.view.compose.NewJoinedState; +import kst4contest.view.compose.SkedRequest; import kst4contest.view.compose.TimelineState; import kst4contest.view.compose.ChatInputState; import kst4contest.view.compose.MainMenuState; @@ -77,6 +85,9 @@ import kst4contest.controller.OperatorProfilePaths; import kst4contest.model.OperatorProfile; import kst4contest.model.OperatorProfileSelection; import kst4contest.view.map.OfflineDemImportService; +import kst4contest.view.map.MapCallsignRawSnapshot; +import kst4contest.view.map.MapCallsignRawSnapshotBuilder; +import kst4contest.view.map.PathAnalysisResult; import kst4contest.controller.WorkedGrossFieldCache; @@ -121,8 +132,6 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos private Predicate lastAppliedChatMemberFilterPredicate = null; - private Band selectedReachabilityBandOverride = null; - private kst4contest.view.feed.TimelineFeed timelineFeed; private kst4contest.view.feed.SelectedStationMessagesFeed selectedStationMessagesFeed; private kst4contest.view.feed.ConnectionStateFeed connectionStateFeed; @@ -264,6 +273,20 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos } } + /** Applies one radio mode to one exact station identity. */ + private void applySelectedStationMessageFilter( + SelectedMessageFilter filter, + ChatMember selectedMember) { + if (filter == null) { + return; + } + + applySelectedCallSignInfoFilter(SelectedStationMessagePredicate.forSelection( + filter, + selectedMember, + chatcontroller.getChatPreferences().getStn_loginCallSign())); + } + /** Follows the PSTRotator QTF in the text field; replaces a JavaFX string binding. */ @@ -2520,7 +2543,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos return; } try { - Path logFile = Path.of(ApplicationFileUtils.getFilePath( + Path logFile = Path.of(ApplicationFileUtils.getDebugFilePath( ApplicationConstants.APPLICATION_NAME, "kst4contest-errors.log")); // Start conservatively until the persisted preference has been loaded. @@ -3005,6 +3028,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos */ private kst4contest.view.i18n.LanguageStore languageStore; private java.util.function.Consumer> composeMemberListener; + private java.util.function.Consumer> composeRecentJoinListener; private java.util.function.Consumer> composeChatListener; private java.util.function.Consumer> composeClusterListener; @@ -3050,20 +3074,45 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos * arrange separately while both windows are on screen; sharing the ids would let a * drag in one silently rearrange the other. */ + EnumSet enabledStationBands = + BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences()); + ReachabilityState reachability = new ReachabilityState( + enabledStationBands, + member -> chatcontroller.getReachabilityService().resolveAutoBand(member), + () -> composeMainWindowState == null + ? null : composeMainWindowState.getSelectedStation().getSelected(), + (member, band) -> { + chatcontroller.getReachabilityService() + .calculateSelectedStationOnDemand(member, band); + return kotlin.Unit.INSTANCE; + }, + () -> { + forceChatMemberFilterRefresh(); + if (composeMainWindowState != null) { + composeMainWindowState.getStations().forceRedraw(); + } + return kotlin.Unit.INSTANCE; + }); + StationSelectionState stationSelection = new StationSelectionState(); + DataTableState stations = new DataTableState<>( StationColumns.INSTANCE.all( member -> { - Band band = resolveReachabilityBandForUi(member); + Band band = reachability.resolveBand(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())), + enabledStationBands), RowKeys.INSTANCE.byValue(StationColumns.INSTANCE::rowKey), "compose-stations", - widths); + widths, + "callsign", + true, + stationSelection, + true); DataTableState directed = new DataTableState<>( DirectedMessageColumns.INSTANCE.all( @@ -3115,44 +3164,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos 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); + applySelectedStationMessageFilter(filter, selectedCallSignInfoStageChatMember); return kotlin.Unit.INSTANCE; }, member -> { @@ -3174,24 +3186,25 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos chatcontroller.rotateTo(kst4contest.controller.RadioSlot.SECOND, azimuth); return kotlin.Unit.INSTANCE; }, - surroundings::getSecondAntennaOffered); + surroundings::getSecondAntennaOffered, + stationSelection); + selectedStation.setSkedRequestSubmitter(this::createSkedFromCompose); - 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); - } + selectedStation.setMessageFilter( + SelectedMessageFilter.fromPreferences(chatcontroller.getChatPreferences())); TopPriorityState topPriority = new TopPriorityState( this::composeTopStations, - member -> selectStationInCompose(selectedStation, stations, member)); + member -> selectStationInCompose(selectedStation, member, StationSelectionSource.PRIORITY)); + NewJoinedState newJoined = new NewJoinedState( + System::currentTimeMillis, + member -> selectStationInCompose( + selectedStation, member, StationSelectionSource.NEW_JOINED)); ChatInputState chatInput = new ChatInputState(new ComposeChatInputActions(this)); composeMainWindowState = new MainWindowState( - BandOpportunityResolver.getEnabledStationBands(chatcontroller.getChatPreferences()), + enabledStationBands, chatcontroller.getChatPreferences(), stations, new StationFilterState(), @@ -3238,7 +3251,19 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos .setGUImainWindowRightSplitPane_dividerposition(positions); requestLayoutSave(); return kotlin.Unit.INSTANCE; - })); + }), + new SplitterState( + MainWindowState.SELECTED_STATION_PANE_COUNT, + chatcontroller.getChatPreferences().getGUIselectedCallSignSplitPane_dividerposition(), + SplitterState.DEFAULT_MIN_PANE_PX, + positions -> { + chatcontroller.getChatPreferences() + .setGUIselectedCallSignSplitPane_dividerposition(positions); + requestLayoutSave(); + return kotlin.Unit.INSTANCE; + }), + newJoined, + reachability); /* * The feed, not the window construction, owns the timeline's data from here. @@ -3249,6 +3274,14 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos composeMainWindowState.getTimeline(), uiDispatcher); selectedStationMessagesFeed = new kst4contest.view.feed.SelectedStationMessagesFeed( composeMainWindowState.getSelectedStationMessages(), uiDispatcher); + /* + * Build the live message mirror before the first Compose selection. The + * selection path already records the selected member before its legacy focus + * callback runs, so waiting for that callback leaves the mirror null and the + * table cannot receive either existing or newly arriving messages. + */ + rebuildSelectedCallSignInfoMessageBinding(); + selectedStationMessagesFeed.push(selectedCallSignInfoMessageBinding.list()); connectionStateFeed = new kst4contest.view.feed.ConnectionStateFeed( composeMainWindowState.getSurroundings(), uiDispatcher); @@ -3258,11 +3291,54 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos * Subscribed first and seeded afterwards, so a message arriving in between is not * missed — the order RosterListBinding uses for the same reason. */ - composeMemberListener = rows -> uiDispatcher.runOnUi(() -> stations.replaceRows(rows)); + composeMemberListener = rows -> uiDispatcher.runOnUi(() -> { + stations.replaceRows(rows); + newJoined.updateActiveMembers(rows); + }); chatcontroller.getLst_chatMemberList().addListener(composeMemberListener); + composeRecentJoinListener = rows -> uiDispatcher.runOnUi(() -> newJoined.updateEvents(rows)); + chatcontroller.getRecentChatJoins().addListener(composeRecentJoinListener); - composeMainWindowState.setStationSelector(member -> { - selectStationInCompose(selectedStation, stations, member); + composeMainWindowState.setStationSelector((member, source) -> { + selectStationInCompose(selectedStation, member, source); + return kotlin.Unit.INSTANCE; + }); + composeMainWindowState.setStationFiltersChanged(() -> { + updateComposeFilters(); + return kotlin.Unit.INSTANCE; + }); + composeMainWindowState.setLayoutSaveRequested(() -> { + requestLayoutSave(); + return kotlin.Unit.INSTANCE; + }); + composeMainWindowState.setMarkStationWorked( + (member, band) -> chatcontroller.markStationWorkedOnBand(member, band)); + composeMainWindowState.setWorkedGrossFieldsProvider( + () -> chatcontroller.getWorkedGrossFieldCache().snapshotWorkedGrossFieldsAny()); + composeMainWindowState.getMapReachability().setRequester((member, band, callback) -> { + java.util.List variants = chatcontroller.getLst_chatMemberList().snapshot() + .stream() + .filter(candidate -> candidate != null + && java.util.Objects.equals(candidate.getCallSignRaw(), member.getCallSignRaw())) + .toList(); + java.util.EnumSet bands = java.util.EnumSet.noneOf(Band.class); + bands.addAll(enabledStationBands); + MapCallsignRawSnapshot snapshot = new MapCallsignRawSnapshotBuilder() + .buildSnapshots(variants, member, bands) + .stream() + .findFirst() + .orElse(null); + if (snapshot == null) { + callback.invoke(PathAnalysisResult.waitingForValidTarget( + chatcontroller.getChatPreferences().getStn_loginLocatorMainCat(), + member.getQra())); + return kotlin.Unit.INSTANCE; + } + chatcontroller.getReachabilityService().requestPathAnalysisForMap( + member, + snapshot, + band, + result -> callback.invoke(result)); return kotlin.Unit.INSTANCE; }); @@ -3281,7 +3357,10 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos } uiDispatcher.runOnUi(() -> topPriority.refresh()); - stations.replaceRows(chatcontroller.getLst_chatMemberList().snapshot()); + java.util.List initialMembers = chatcontroller.getLst_chatMemberList().snapshot(); + stations.replaceRows(initialMembers); + newJoined.updateActiveMembers(initialMembers); + newJoined.updateEvents(chatcontroller.getRecentChatJoins().snapshot()); directed.replaceRows(chatcontroller.toMeMessages()); publicMessages.replaceRows(chatcontroller.getLst_globalChatMessageList().snapshot()); cluster.replaceRows(chatcontroller.getLst_clusterMemberList().snapshot()); @@ -3336,7 +3415,6 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos closeWindowEvent(); return kotlin.Unit.INSTANCE; }, - () -> composeSkedBands(selectedStation.getSelected()), address -> runOnUi(() -> ExternalDocuments.open(address)), () -> { runOnUi(() -> showSelectedCallsignOnMap()); @@ -3348,8 +3426,9 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos } return kotlin.Unit.INSTANCE; }, - member -> selectStationInCompose(selectedStation, stations, member), - message -> onDirectedMessageClickedInCompose(selectedStation, stations, composeMainWindowState.getChatInput(), message), + member -> selectStationInCompose(selectedStation, member, StationSelectionSource.STATION_TABLE), + message -> onDirectedMessageClickedInCompose(selectedStation, composeMainWindowState.getChatInput(), message), + (message, source) -> onMessageSenderClickedInCompose(selectedStation, message, source), this::isOwnChatMessage, this::chatMessageAgeSeconds, () -> { @@ -3367,7 +3446,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos ChatMember resolved = resolveChatMemberForCallRawAndCategory( candidate.getCallSignRaw(), candidate.getPreferredChatCategory()); if (resolved != null) { - selectStationInCompose(selectedStation, stations, resolved); + selectStationInCompose(selectedStation, resolved, StationSelectionSource.TIMELINE); composeMainWindowState.getChatInput().prepareCq(resolved.getCallSign(), false); // Sync with JavaFX state just in case @@ -3391,6 +3470,29 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos final int minutes, final Band band, final String mode) { + boolean remindPm = false; + String reminderOffsets = SelectedStationState.Companion.getREMINDER_OFFSETS().get(0); + if (composeMainWindowState != null) { + SelectedStationState state = composeMainWindowState.getSelectedStation(); + remindPm = state.getRemindPm(); + reminderOffsets = state.getReminderOffsets(); + } + return createSkedFromCompose(new SkedRequest( + selectedMember, + minutes, + band, + mode, + remindPm, + reminderOffsets)); + } + + private boolean createSkedFromCompose(final SkedRequest request) { + if (request == null) { + return false; + } + final ChatMember selectedMember = request.getTarget(); + final int minutes = request.getMinutes(); + final Band band = request.getBand(); if (selectedMember == null || band == null) { return false; @@ -3412,17 +3514,14 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos 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()); + if (request.getRemindPm()) { + List offsets = parseMinuteOffsets(request.getReminderOffsets()); chatcontroller.getSkedReminderService().armReminders( sked.getTargetChatCallsign(), sked.getTargetChatCategory(), skedTime, offsets ); - } } return true; @@ -3462,28 +3561,6 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos 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<>(); @@ -3515,21 +3592,13 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos */ private kotlin.Unit selectStationInCompose( SelectedStationState panel, - DataTableState table, - ChatMember member) { + ChatMember member, + StationSelectionSource source) { - panel.select(member); - table.select(member); + panel.select(member, source); selectedCallSignInfoStageChatMember = member; - if (selectedCallSignInfoMessageBinding != null) { - selectedCallSignInfoMessageBinding.refresh(); - uiDispatcher.runOnUi(() -> { - if (selectedStationMessagesFeed != null) { - selectedStationMessagesFeed.push(selectedCallSignInfoMessageBinding.list()); - } - }); - } + applySelectedStationMessageFilter(panel.getMessageFilter(), member); if (member != null && chatcontroller.getScoreService() != null) { chatcontroller.getScoreService().setSelectedChatMember(member); @@ -3545,26 +3614,41 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos private kotlin.Unit onDirectedMessageClickedInCompose( SelectedStationState panel, - DataTableState stationsTable, ChatInputState chatInput, ChatMessage message) { - + + String ownCall = chatcontroller.getChatPreferences().getStn_loginCallSign(); + StationIdentity target = DirectedMessageStationTarget.forMessage(message, ownCall); + if (target == null) { + return kotlin.Unit.INSTANCE; + } + + ChatMember activeTarget = chatcontroller.findActiveChatMember(target.getCallSign(), message.getChatCategory()); + if (activeTarget != null) { + selectStationInCompose(panel, activeTarget, StationSelectionSource.DIRECTED_MESSAGE); + } else { + chatInput.prepareCq(target.getCallSign(), false); + if (message.getSender() != null && message.getSender().getCallSign().equalsIgnoreCase(ownCall)) { + rememberAutoPreparedCqTarget(target.getCallSign(), message.getChatCategory()); + } + } + + return kotlin.Unit.INSTANCE; + } + + private kotlin.Unit onMessageSenderClickedInCompose( + SelectedStationState panel, + ChatMessage message, + StationSelectionSource source) { if (message == null || message.getSender() == null) { 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); - rememberAutoPreparedCqTarget(receiverCallsign, message.getChatCategory()); - } else { - selectStationInCompose(panel, stationsTable, message.getSender()); - - chatInput.prepareCq(message.getSender().getCallSign(), false); - prepareCqTextForSelectedChatMember(message.getSender(), false); - focusChatMemberAndPrepareCq(message.getSender(), false); + ChatMember sender = chatcontroller.findActiveChatMember( + message.getSender().getCallSign(), message.getChatCategory()); + if (sender != null) { + selectStationInCompose(panel, sender, source); } - return kotlin.Unit.INSTANCE; } @@ -3620,6 +3704,10 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos chatcontroller.getLst_chatMemberList().removeListener(composeMemberListener); composeMemberListener = null; } + if (composeRecentJoinListener != null) { + chatcontroller.getRecentChatJoins().removeListener(composeRecentJoinListener); + composeRecentJoinListener = null; + } if (composeChatListener != null) { chatcontroller.getLst_globalChatMessageList().removeListener(composeChatListener); composeChatListener = null; @@ -3825,7 +3913,12 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos requestLayoutSave(); return kotlin.Unit.INSTANCE; }, - SettingsTabsKt.settingsButtons(chatcontroller, this, settingsNotices, draft)); + SettingsTabsKt.settingsButtons( + chatcontroller, + this, + settingsNotices, + draft, + this::settingsConnectionState)); } /* @@ -4222,31 +4315,12 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos * @return manually selected band or automatic band */ private Band resolveReachabilityBandForUi(ChatMember member) { - if (selectedReachabilityBandOverride != null) { - return selectedReachabilityBandOverride; + if (composeMainWindowState != null) { + return composeMainWindowState.getReachability().resolveBand(member); } return chatcontroller.getReachabilityService().resolveAutoBand(member); } - /** - * Parses the reachability ComboBox selection. - * - * @param selectedLabel selected UI text - * @return selected band, or null for Auto - */ - private Band parseReachabilityBandSelection(String selectedLabel) { - if (selectedLabel == null || selectedLabel.isBlank() || "Auto".equalsIgnoreCase(selectedLabel)) { - return null; - } - - for (Band band : Band.values()) { - if (selectedLabel.equalsIgnoreCase(band.getDisplayLabel())) { - return band; - } - } - return null; - } - /** * Formats the projected priority score for the station table. * diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/ChatInputRow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/ChatInputRow.kt index 65896e6b..db59fe40 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/ChatInputRow.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/ChatInputRow.kt @@ -3,7 +3,6 @@ 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 @@ -17,7 +16,6 @@ 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 @@ -37,9 +35,8 @@ 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 androidx.compose.ui.unit.dp import kst4contest.model.ChatPreferences @@ -59,9 +56,13 @@ fun ShortcutButtonRow( modifier: Modifier = Modifier, ) { FlowRow( - modifier = modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 0.dp), - horizontalArrangement = Arrangement.spacedBy(0.dp), - verticalArrangement = Arrangement.spacedBy(0.dp), + modifier = modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)) + .border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant) + .padding(horizontal = 2.dp, vertical = 1.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), ) { shortcuts.forEach { shortcut -> when (shortcut) { @@ -109,38 +110,9 @@ private fun ShortcutButton(label: String, accented: Boolean, onClick: () -> Unit 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, - ) - } + Form.button(text = label, accented = true, onClick = onClick) } -/** `-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. @@ -207,6 +179,7 @@ fun ChatInputRow( stored = antennaQtf.value?.toString().orEmpty(), onCommit = { text -> text.toDoubleOrNull()?.let { prefs.getActualQTF().set(it) } }, tooltip = kst4contest.view.i18n.CurrentStrings.get().chatQtfTooltip, + width = QTF_FIELD_WIDTH, ) if (so2rEnabled) { // Display only: rotator 2 writes it, the operator does not. @@ -215,6 +188,7 @@ fun ChatInputRow( onCommit = { }, tooltip = kst4contest.view.i18n.CurrentStrings.get().chatQtf2Tooltip, editable = false, + width = QTF_FIELD_WIDTH, ) } } @@ -234,6 +208,7 @@ private fun InlineCommittedField( onCommit: (String) -> Unit, tooltip: String, editable: Boolean = true, + width: Dp = QRG_FIELD_WIDTH, ) { var draft by remember(stored) { mutableStateOf(stored) } @@ -243,7 +218,7 @@ private fun InlineCommittedField( onValueChange = { draft = it }, enabled = editable, modifier = Modifier - .width(QRG_FIELD_WIDTH) + .width(width) .onFocusChanged { focus -> if (!focus.isFocused && draft != stored) onCommit(draft) }, @@ -267,7 +242,8 @@ private fun TooltipChip(text: String) { } } -private val QRG_FIELD_WIDTH = 70.dp +private val QRG_FIELD_WIDTH = 96.dp +private val QTF_FIELD_WIDTH = 56.dp /** * The message field. @@ -276,6 +252,7 @@ private val QRG_FIELD_WIDTH = 70.dp * both were on the scene in JavaFX, which means they worked wherever the focus was; here * they are on the field, and the window handles them for the rest of the surface. */ +@OptIn(ExperimentalFoundationApi::class) @Composable private fun MessageField( state: ChatInputState, @@ -284,12 +261,21 @@ private fun MessageField( ) { val scheme = MaterialTheme.colorScheme + TooltipArea( + tooltip = { + if (state.value.text.isNotBlank()) { + TooltipChip(state.value.text) + } + }, + modifier = modifier, + ) { Box( - modifier = modifier - .heightIn(min = Density.FIELD_MIN_HEIGHT) + modifier = Modifier + .fillMaxWidth() + .heightIn(min = Density.CONTROL_HEIGHT) .border(1.5.dp, scheme.primary, Density.BUTTON_SHAPE) .background(scheme.surface, Density.BUTTON_SHAPE) - .padding(horizontal = 6.dp, vertical = 4.dp), + .padding(horizontal = 6.dp), contentAlignment = Alignment.CenterStart, ) { BasicTextField( @@ -307,10 +293,11 @@ private fun MessageField( } }, singleLine = true, - textStyle = LocalTextStyle.current.copy(color = scheme.onSurface), + textStyle = compactControlTextStyle().copy(color = scheme.onSurface), cursorBrush = SolidColor(scheme.primary), ) } + } } /** The vertical separator that sets the frequency fields apart from the send controls. */ @@ -319,7 +306,7 @@ private fun VerticalRule() { Box( Modifier .width(Density.HAIRLINE) - .height(Density.FIELD_MIN_HEIGHT) + .height(Density.CONTROL_HEIGHT) .background(MaterialTheme.colorScheme.outline), ) } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/ConnectionBar.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/ConnectionBar.kt index 98578dc2..8422617d 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/ConnectionBar.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/ConnectionBar.kt @@ -7,15 +7,9 @@ import androidx.compose.foundation.layout.Row import androidx.compose.runtime.Composable import kst4contest.view.i18n.LocalStrings import kst4contest.view.i18n.Strings -import kst4contest.controller.ChatController +import kst4contest.controller.On4KstConnectionState 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. * @@ -26,16 +20,9 @@ enum class ConnectionState { DISCONNECTED, CONNECTED_NOT_LOGGED_IN, LOGGED_IN } */ class ConnectionBarState( private val prefs: ChatPreferences, - private val controller: ChatController, + private val connectionState: () -> On4KstConnectionState, ) { - val connectionState: ConnectionState - get() = when { - controller.isConnectedAndLoggedIn -> ConnectionState.LOGGED_IN - controller.isConnectedAndNOTLoggedIn -> ConnectionState.CONNECTED_NOT_LOGGED_IN - else -> ConnectionState.DISCONNECTED - } - /** * The label of the Connect button, which names the chat the station will land in — * and both chats when the second one is enabled, so the operator sees before @@ -62,13 +49,13 @@ class ConnectionBarState( return label } - /** Only a disconnected session can connect; the JavaFX button locked itself. */ + /** A new attempt is safe only after the manager has completed its disconnect. */ val canConnect: Boolean - get() = connectionState != ConnectionState.LOGGED_IN + get() = connectionState() == On4KstConnectionState.DISCONNECTED - /** Disconnecting needs a session that is actually logged in. */ + /** A running attempt, online session or reconnect wait can be stopped by the operator. */ val canDisconnect: Boolean - get() = connectionState == ConnectionState.LOGGED_IN + get() = connectionState().isConnectionAttemptActive } @OptIn(ExperimentalLayoutApi::class) diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableState.kt index 62b923d0..67caa0b8 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableState.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableState.kt @@ -162,6 +162,28 @@ interface ColumnWidthStore { fun requestSave() } +/** + * Optional shared row selection for tables whose selected entity is also displayed by + * other components. Ordinary event tables keep their selection inside [DataTableState]. + */ +interface TableSelectionModel { + + val selected: T? + + /** Changes whenever [selected] changes, including a repeated explicit selection. */ + val selectionRevision: Long + + /** Changes only when the table should scroll to the current selection. */ + val scrollRequestRevision: Long + + fun select(row: T?) + + /** Replaces the selected instance after a roster refresh without requesting a scroll. */ + fun reconcile(row: T?) + + fun requestScroll() +} + /** * The state of a multi-column table: its rows, its sorting and its selection. * @@ -170,12 +192,16 @@ interface ColumnWidthStore { * data that shows up as a stale display rather than as an unresponsive one — harder * to notice and worse in effect. */ -class DataTableState( +class DataTableState @JvmOverloads constructor( 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, + initialSortColumnId: String? = null, + initialSortAscending: Boolean = true, + private val selectionModel: TableSelectionModel? = null, + private val keepSelectionVisible: Boolean = false, ) { /** @@ -251,19 +277,29 @@ class DataTableState( var rows: List by mutableStateOf(emptyList()) private set - var sortedColumnId: String? by mutableStateOf(null) + var sortedColumnId: String? by mutableStateOf( + initialSortColumnId?.takeIf { requested -> columns.any { it.id == requested } }, + ) private set - var sortAscending: Boolean by mutableStateOf(true) + var sortAscending: Boolean by mutableStateOf(initialSortAscending) private set private var selectedKey: Any? by mutableStateOf(null) - private var sortedCache: List? = null + private var internalSelectionRevision: Long by mutableStateOf(0L) + + private var internalScrollRequestRevision: Long by mutableStateOf(0L) + + private var filteredCache: List? = null + + private var visibleCache: List? = null + + private var cachedSelectionRevision: Long = Long.MIN_VALUE private var _rowFilter by mutableStateOf<((T) -> Boolean)?>(null, androidx.compose.runtime.neverEqualPolicy()) - /** Optional filter predicate. If set, [visibleRows] will only include matching rows. */ + /** Optional filter predicate. If set, [filteredRows] only includes matching rows. */ var rowFilter: ((T) -> Boolean)? get() = _rowFilter set(value) { @@ -294,7 +330,7 @@ class DataTableState( * 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 + val filteredRows: List get() { /* * Read before the cache is consulted, and that order is the whole point. @@ -307,45 +343,84 @@ class DataTableState( val currentRows = rows val currentFilter = rowFilter contentRevision // read for the subscription: forceRedraw drops the cache - val currentColumn = sortedColumnId + val currentColumnId = sortedColumnId val ascending = sortAscending + filteredCache?.let { return it } - sortedCache?.let { return it } + val filtered = if (currentFilter != null) currentRows.filter(currentFilter) else currentRows + val sorted = sort(filtered, currentColumnId, ascending) - val filteredRows = if (currentFilter != null) currentRows.filter(currentFilter) else currentRows - - val column = columns.firstOrNull { it.id == currentColumn } - val sorted = if (column == null) { - filteredRows - } else { - /* - * The displayed text is read once per row rather than once per - * comparison, and sortedBy is stable, so rows that compare equal keep - * the order they arrived in. Etappe 2 lost that: the JavaFX SortedList - * inserted by binary search and put ties wherever it landed, which made - * rows jump. - */ - val order = column.comparator - ?: compareBy(filteredRows.associateWith { column.value(it) }::getValue) - - if (ascending) { - filteredRows.sortedWith(order) - } else { - filteredRows.sortedWith(order.reversed()) - } - } - - sortedCache = sorted + filteredCache = sorted return sorted } + /** + * Rows drawn by this table. A station selected outside the table can optionally be + * included even when the active filter rejects it; [filteredRows] remains the exact + * shared filter result used by the map. + */ + val visibleRows: List + get() { + val currentRows = rows + val filtered = filteredRows + val revision = selectionRevision + + if (!keepSelectionVisible) { + return filtered + } + + if (visibleCache != null && cachedSelectionRevision == revision) { + return visibleCache.orEmpty() + } + + val selected = selectedRow + val visible = if (selected == null || filtered.any { keyOf(it) == keyOf(selected) }) { + filtered + } else if (currentRows.any { keyOf(it) == keyOf(selected) }) { + val filteredKeys = filtered.mapTo(HashSet()) { row -> keyOf(row) } + val selectedKey = keyOf(selected) + sort(currentRows.filter { row -> keyOf(row) == selectedKey || keyOf(row) in filteredKeys }) + } else { + filtered + } + + cachedSelectionRevision = revision + visibleCache = visible + return visible + } + + private fun sort( + source: List, + columnId: String? = sortedColumnId, + ascending: Boolean = sortAscending, + ): List { + val column = columns.firstOrNull { it.id == columnId } ?: return source + + /* + * The displayed text is read once per row rather than once per comparison, and + * Kotlin's sort is stable, so equal values keep their source order. + */ + val order = column.comparator + ?: compareBy(source.associateWith { column.value(it) }::getValue) + + return if (ascending) source.sortedWith(order) else source.sortedWith(order.reversed()) + } + private fun invalidateOrder() { - sortedCache = null + filteredCache = null + visibleCache = 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 } } + get() = selectionModel?.selected + ?: selectedKey?.let { key -> rows.firstOrNull { rowKey.keyOf(it) == key } } + + val selectionRevision: Long + get() = selectionModel?.selectionRevision ?: internalSelectionRevision + + val scrollRequestRevision: Long + get() = selectionModel?.scrollRequestRevision ?: internalScrollRequestRevision /** * Replaces the rows. @@ -356,18 +431,43 @@ class DataTableState( * neighbour, which would silently point the operator at someone else. */ fun replaceRows(newRows: List) { + val selectedIdentity = selectionModel?.selected?.let(rowKey::keyOf) ?: selectedKey + rows = newRows rowKey.retainOnly(newRows) invalidateOrder() - val key = selectedKey - if (key != null && newRows.none { rowKey.keyOf(it) == key }) { - selectedKey = null + val replacement = selectedIdentity?.let { key -> newRows.firstOrNull { rowKey.keyOf(it) == key } } + + if (selectionModel != null) { + if (selectedIdentity != null) { + selectionModel.reconcile(replacement) + } + } else if (selectedIdentity != null) { + selectedKey = replacement?.let(rowKey::keyOf) + internalSelectionRevision++ } } fun select(row: T?) { - selectedKey = row?.let(rowKey::keyOf) + if (selectionModel != null) { + selectionModel.select(row) + } else { + selectedKey = row?.let(rowKey::keyOf) + internalSelectionRevision++ + } + visibleCache = null + } + + /** Requests navigation to the current selection without changing it. */ + fun requestScrollToSelection() { + if (selectedRow == null) return + + if (selectionModel != null) { + selectionModel.requestScroll() + } else { + internalScrollRequestRevision++ + } } /** @@ -377,7 +477,10 @@ class DataTableState( */ fun keyOf(row: T): Any = rowKey.keyOf(row) - fun isSelected(row: T): Boolean = selectedKey != null && rowKey.keyOf(row) == selectedKey + fun isSelected(row: T): Boolean { + val selected = selectedRow ?: return false + return rowKey.keyOf(row) == rowKey.keyOf(selected) + } /** * Sorts by a column, cycling ascending, descending, unsorted. diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableView.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableView.kt index aa833b31..a335ca60 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableView.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableView.kt @@ -1,10 +1,9 @@ package kst4contest.view.compose import androidx.compose.foundation.background +import androidx.compose.foundation.border 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 @@ -30,6 +29,9 @@ 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.PointerEventType +import androidx.compose.ui.input.pointer.isSecondaryPressed +import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.input.pointer.pointerHoverIcon import androidx.compose.ui.text.style.TextOverflow import java.awt.Cursor @@ -44,12 +46,19 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.IntOffset +import kst4contest.view.i18n.LocalStrings +import kotlin.math.roundToInt /** * A multi-column table. @@ -71,15 +80,19 @@ import androidx.compose.ui.unit.dp * the supported way to reach the hover text the JavaFX cells had, and losing that * loses the only way to read a value that does not fit its column. */ -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) @Composable fun DataTableView( state: DataTableState, modifier: Modifier = Modifier, rowStyle: ((T) -> CellAccent?)? = null, + emphasizeCell: ((T, DataColumn) -> Boolean)? = null, onRowClick: ((T) -> Unit)? = null, + onRowContextMenu: ((T, IntOffset) -> Unit)? = null, + selectOnRowClick: Boolean = true, ) { val density = LocalDensity.current + val automaticWidths = rememberAutomaticColumnWidths(state) val horizontal = rememberScrollState() val vertical = rememberLazyListState() @@ -113,16 +126,36 @@ fun DataTableView( } } + var handledScrollRequest by remember(state) { mutableStateOf(0L) } + val scrollRequest = state.scrollRequestRevision + val displayedRows = state.visibleRows + + /* + * Resolve the index only after reading the current filtered and sorted rows. If the + * selected row has not arrived yet, leave the request pending and retry when that + * list changes. A handled request is never repeated by an ordinary data refresh. + */ + LaunchedEffect(scrollRequest, displayedRows) { + if (scrollRequest <= handledScrollRequest) return@LaunchedEffect + + val index = displayedRows.indexOfFirst(state::isSelected) + if (index >= 0) { + vertical.scrollToItem(index) + handledScrollRequest = scrollRequest + } + } + /* * Both directions scroll. The columns of these two tables need about 890dp and * 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 - + Box( + modifier = modifier + .fillMaxSize() + .border(Density.HAIRLINE, MaterialTheme.colorScheme.outline), + ) { Column(modifier = Modifier.fillMaxSize()) { /* * A second header row above the first, spanning the columns that share a @@ -150,9 +183,8 @@ fun DataTableView( 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 + effectiveColumnWidthDp(state, column, automaticWidths).toDouble() + + Density.COLUMN_HANDLE_WIDTH.value } Box( @@ -202,7 +234,7 @@ fun DataTableView( Box( modifier = Modifier - .columnWidth(state, column, if (column.id == lastColumnId) slack else 0.dp) + .columnWidth(state, column, automaticWidths) .fillMaxHeight() .clickable { state.toggleSort(column.id) } .pointerInput(column.id, index) { @@ -211,7 +243,7 @@ fun DataTableView( onDragCancel = { dragged = 0f }, ) { _, dragAmount -> dragged += dragAmount - val step = with(density) { DEFAULT_COLUMN_WIDTH.toPx() } / 2f + val step = with(density) { DEFAULT_COLUMN_WIDTH_DP.dp.toPx() } / 2f if (dragged > step) { state.moveColumn(column.id, index + 1) @@ -258,8 +290,11 @@ fun DataTableView( * 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() + val current = effectiveColumnWidthDp( + state, + column, + automaticWidths, + ).toDouble() state.resizeColumn(column.id, current + deltaDp) } @@ -278,7 +313,12 @@ fun DataTableView( HorizontalDivider(thickness = Density.HAIRLINE) - Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.45f)), + ) { LazyColumn( state = vertical, modifier = Modifier @@ -291,6 +331,9 @@ fun DataTableView( val accent = rowStyle?.invoke(row) val isEven = index % 2 == 0 val _forceRedraw = state.contentRevision + var rowCoordinates by remember(state.keyOf(row)) { + mutableStateOf(null) + } Column(modifier = Modifier.fillMaxWidth()) { Row( @@ -306,9 +349,25 @@ fun DataTableView( } ) .clickable { - state.select(row) + if (selectOnRowClick) { + state.select(row) + } onRowClick?.invoke(row) } + .onGloballyPositioned { rowCoordinates = it } + .onPointerEvent(PointerEventType.Press) { event -> + if (event.buttons.isSecondaryPressed) { + val local = event.changes.firstOrNull()?.position + val window = local?.let { rowCoordinates?.localToWindow(it) } + if (window != null) { + onRowContextMenu?.invoke( + row, + IntOffset(window.x.roundToInt(), window.y.roundToInt()), + ) + event.changes.forEach { it.consume() } + } + } + } .heightIn(min = Density.ROW_MIN_HEIGHT), verticalAlignment = Alignment.CenterVertically, ) { @@ -318,7 +377,7 @@ fun DataTableView( Box( modifier = Modifier - .columnWidth(state, column, if (column.id == lastColumnId) slack else 0.dp) + .columnWidth(state, column, automaticWidths) .fillMaxHeight() .background(cell?.background ?: Color.Transparent) .padding(horizontal = 4.dp, vertical = 2.dp), @@ -345,6 +404,11 @@ fun DataTableView( Text( value, style = MaterialTheme.typography.bodyMedium, + fontWeight = if (emphasizeCell?.invoke(row, column) == true) { + FontWeight.Bold + } else { + null + }, maxLines = 1, overflow = TextOverflow.Ellipsis, color = cell?.foreground @@ -378,6 +442,15 @@ fun DataTableView( } } + if (state.visibleRows.isEmpty()) { + Text( + text = LocalStrings.current.tableEmpty, + modifier = Modifier.align(Alignment.Center).padding(8.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.outline, + ) + } + VerticalScrollbar( adapter = rememberScrollbarAdapter(vertical), modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(), @@ -402,39 +475,71 @@ fun DataTableView( } -/** - * 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`. - */ +/** A stored width wins over the measured automatic width, even when it is narrower. */ @Composable private fun Modifier.columnWidth( state: DataTableState, column: DataColumn, - extra: Dp = 0.dp, + automaticWidths: Map, ): 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) + return this.width(effectiveColumnWidthDp(state, column, automaticWidths).dp) } /** - * 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. + * Measures automatic columns once from their headers and once more when the first + * source rows arrive. Later chat events do not resize a live table. */ @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 - } +private fun rememberAutomaticColumnWidths(state: DataTableState): Map { + val density = LocalDensity.current + val measurer = rememberTextMeasurer() + val headerStyle = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Bold) + val cellStyle = MaterialTheme.typography.bodyMedium + val hasSourceRows = state.rows.isNotEmpty() + val titles = state.orderedColumns.associate { column -> column.id to column.title() } - return (available.value - used).coerceAtLeast(0.0).dp + return remember( + state, + density.density, + density.fontScale, + headerStyle, + cellStyle, + titles, + hasSourceRows, + ) { + val sample = if (hasSourceRows) state.rows.take(AUTO_WIDTH_SAMPLE_SIZE) else emptyList() + + state.orderedColumns.associate { column -> + val title = titles.getValue(column.id) + SORT_INDICATOR_SAMPLE + val headerWidthPx = measurer.measure(title, headerStyle).size.width + val contentWidthPx = sample.maxOfOrNull { row -> + val value = runCatching { column.value(row) }.getOrDefault("") + measurer.measure(value, cellStyle).size.width + } ?: 0 + val measuredDp = with(density) { + maxOf(headerWidthPx, contentWidthPx).toDp().value + CELL_HORIZONTAL_PADDING_DP + } + val maximumDp = (AUTO_WIDTH_MAX_BASE_DP * column.weight.coerceAtLeast(0.5f)) + .coerceIn(AUTO_WIDTH_MIN_MAX_DP, AUTO_WIDTH_ABSOLUTE_MAX_DP) + + column.id to measuredDp.coerceIn(AUTO_WIDTH_MIN_DP, maximumDp) + } + } } -private val DEFAULT_COLUMN_WIDTH = 85.dp +internal fun effectiveColumnWidthDp( + state: DataTableState, + column: DataColumn, + automaticWidths: Map, +): Float = state.widthOf(column.id)?.toFloat() + ?: automaticWidths[column.id] + ?: DEFAULT_COLUMN_WIDTH_DP * column.weight + +private const val AUTO_WIDTH_SAMPLE_SIZE = 160 +private const val AUTO_WIDTH_MIN_DP = 32f +private const val AUTO_WIDTH_MIN_MAX_DP = 90f +private const val AUTO_WIDTH_MAX_BASE_DP = 180f +private const val AUTO_WIDTH_ABSOLUTE_MAX_DP = 360f +private const val CELL_HORIZONTAL_PADDING_DP = 10f +private const val DEFAULT_COLUMN_WIDTH_DP = 85f +private const val SORT_INDICATOR_SAMPLE = " ▲" diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/Density.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/Density.kt index a29de120..443b4b60 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/Density.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/Density.kt @@ -13,27 +13,36 @@ import androidx.compose.ui.unit.dp * 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. + * Interactive controls share one minimum visible height. Their common font and padding + * keep the standard size exact while still allowing a larger configured font to grow. */ internal object Density { - /** Text fields, pickers and anything else on the field column. */ - val FIELD_MIN_HEIGHT = 30.dp + /** Minimum for buttons, text fields, choices and checkbox rows in the desktop UI. */ + val CONTROL_HEIGHT = 26.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) + /** Text stays vertically centred by the control instead of by extra padding. */ + val FIELD_CONTENT_PADDING = PaddingValues(horizontal = 6.dp, vertical = 0.dp) - val BUTTON_MIN_HEIGHT = 24.dp - - val BUTTON_CONTENT_PADDING = PaddingValues(horizontal = 8.dp, vertical = 1.dp) + val BUTTON_CONTENT_PADDING = PaddingValues(horizontal = 6.dp, vertical = 0.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 ROW_MIN_HEIGHT = CONTROL_HEIGHT + + /** Equal width for all eight compass-direction filter buttons. */ + val DIRECTION_BUTTON_WIDTH = 28.dp + + /** Two-letter compass labels need only a small inset inside their fixed width. */ + val DIRECTION_BUTTON_HORIZONTAL_PADDING = 2.dp + + /** Enough room for the NOT-QRV and sked groups to remain usable side by side. */ + val SELECTED_ACTION_GROUPS_ROW_MIN_WIDTH = 450.dp + + /** Keeps the station actions usable when a stored splitter position is too low. */ + val SELECTED_ACTION_AREA_MIN_HEIGHT = 160.dp val TAB_MIN_HEIGHT = 30.dp @@ -55,6 +64,9 @@ internal object Density { /** Between buttons in a row. */ val BUTTON_GAP = 6.dp + /** Compact inset for a visually grouped settings section. */ + val GROUP_CONTENT_PADDING = PaddingValues(horizontal = 8.dp, vertical = 6.dp) + /** Around a selected row, and the hairline between rows. */ val SELECTION_BORDER = 1.5.dp diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/FormControls.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/FormControls.kt index 880e231a..24447c9d 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/FormControls.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/FormControls.kt @@ -6,6 +6,7 @@ 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.PaddingValues import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth @@ -17,7 +18,11 @@ import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.interaction.collectIsHoveredAsState +import androidx.compose.foundation.interaction.collectIsFocusedAsState import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.border +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.toggleable import androidx.compose.ui.graphics.Brush import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -43,17 +48,29 @@ 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.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.luminance 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.TextStyle 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.TextUnit import androidx.compose.ui.unit.dp +import androidx.compose.ui.semantics.Role + +/** One font size and natural line box for every compact interactive control. */ +@Composable +internal fun compactControlTextStyle(): TextStyle = + MaterialTheme.typography.bodyMedium.copy(lineHeight = TextUnit.Unspecified) /** * Form controls shared by the settings tabs. @@ -232,6 +249,7 @@ object Form { val draft = rememberDraft(selected) labelled(label) { + CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) { Box { OutlinedButton( onClick = { expanded = true }, @@ -240,18 +258,20 @@ object Form { contentPadding = Density.FIELD_CONTENT_PADDING, modifier = Modifier .fillMaxWidth() - .heightIn(min = Density.FIELD_MIN_HEIGHT), + .heightIn(min = Density.CONTROL_HEIGHT), ) { Text( draft.value?.let(describe) ?: kst4contest.view.i18n.LocalStrings.current.formNotSelected, - style = MaterialTheme.typography.bodyMedium, + style = compactControlTextStyle(), modifier = Modifier.fillMaxWidth(), ) } DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { items.forEach { item -> DropdownMenuItem( - text = { Text(describe(item), style = MaterialTheme.typography.bodyMedium) }, + text = { Text(describe(item), style = compactControlTextStyle()) }, + contentPadding = Density.FIELD_CONTENT_PADDING, + modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT), onClick = { expanded = false draft.value = item @@ -261,6 +281,7 @@ object Form { } } } + } } } @@ -271,30 +292,39 @@ object Form { selected: T?, describe: (T) -> String, enabled: Boolean = true, + trailingText: String = "", 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)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT), + ) { + if (label.isNotBlank()) { + Text(label, style = compactControlTextStyle(), modifier = Modifier.padding(end = 4.dp)) + } + CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) { Box { OutlinedButton( onClick = { expanded = true }, enabled = enabled, shape = Density.BUTTON_SHAPE, contentPadding = Density.FIELD_CONTENT_PADDING, - modifier = Modifier.heightIn(min = Density.FIELD_MIN_HEIGHT), + modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT), ) { Text( draft.value?.let(describe) ?: kst4contest.view.i18n.LocalStrings.current.formNotSelected, - style = MaterialTheme.typography.bodyMedium, + style = compactControlTextStyle(), ) } DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { items.forEach { item -> DropdownMenuItem( - text = { Text(describe(item), style = MaterialTheme.typography.bodyMedium) }, + text = { Text(describe(item), style = compactControlTextStyle()) }, + contentPadding = Density.FIELD_CONTENT_PADDING, + modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT), onClick = { expanded = false draft.value = item @@ -304,32 +334,81 @@ object Form { } } } + } + if (trailingText.isNotBlank()) { + Text( + trailingText, + style = compactControlTextStyle(), + modifier = Modifier.padding(start = 4.dp), + ) + } } } @Composable - fun check(label: String, value: Boolean, onChange: (Boolean) -> Unit) { + fun check( + label: String, + value: Boolean, + enabled: Boolean = true, + onChange: (Boolean) -> Unit, + ) { val draft = rememberDraft(value) Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.heightIn(min = Density.ROW_MIN_HEIGHT), + modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT), ) { - CompactCheckbox(draft.value) { ticked -> + CompactCheckbox(draft.value, enabled = enabled) { ticked -> draft.value = ticked onChange(ticked) } - Text(label, modifier = Modifier.padding(start = 6.dp)) + Text( + label, + color = if (enabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + style = compactControlTextStyle(), + modifier = Modifier.padding(start = 4.dp), + ) } } + /** A compact two-state button for dense desktop option groups. */ + @Composable + fun toggle( + text: String, + checked: Boolean, + enabled: Boolean = true, + modifier: Modifier = Modifier, + onCheckedChange: (Boolean) -> Unit, + ) { + CompactToggleButton( + text = text, + checked = checked, + enabled = enabled, + modifier = modifier, + onCheckedChange = onCheckedChange, + ) + } + @Composable fun int(label: String, value: Int, onChange: (Int) -> Unit) = number(label, value, { it.trim().toIntOrNull() }, onChange) @Composable - fun decimal(label: String, value: Double, onChange: (Double) -> Unit) = - number(label, value, { it.trim().replace(',', '.').toDoubleOrNull() }, onChange) + fun decimal( + label: String, + value: Double, + isValid: (Double) -> Boolean = { true }, + onChange: (Double) -> Unit, + ) = number( + label, + value, + { text -> text.trim().replace(',', '.').toDoubleOrNull()?.takeIf(isValid) }, + onChange, + ) /** * The number field behind [int] and [decimal]. @@ -401,6 +480,30 @@ object Form { } } + /** A compact settings group that needs stronger separation than a labelled divider. */ + @Composable + fun panelSection(title: String, content: @Composable () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 10.dp) + .background( + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f), + shape = Density.BUTTON_SHAPE, + ) + .border( + width = Density.HAIRLINE, + color = MaterialTheme.colorScheme.outlineVariant, + shape = Density.BUTTON_SHAPE, + ) + .padding(Density.GROUP_CONTENT_PADDING), + verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP), + ) { + Text(title, style = MaterialTheme.typography.titleSmall) + content() + } + } + /** * A section whose controls flow across the width instead of stacking. * @@ -430,26 +533,49 @@ object Form { } /** - * 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. + * The shared desktop button used in settings, filter and shortcut areas. * * 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) { + fun button( + text: String, + enabled: Boolean = true, + accented: Boolean = false, + selected: Boolean = false, + modifier: Modifier = Modifier, + horizontalPadding: Dp = 6.dp, + onClick: () -> Unit, + ) { val palette = LocalJavaFxPalette.current val interactions = remember { MutableInteractionSource() } val hovered by interactions.collectIsHoveredAsState() val pressed by interactions.collectIsPressedAsState() - val hoverGradient = palette.buttonHoverGradient + val accentGradient = if (palette.windowBackground.luminance() < 0.5f) { + listOf(Color(0xFF008000), Color(0xFF90EE90)) + } else { + listOf(Color(0xFF00FFFF), Color(0xFFFF99FF)) + } + val backgroundGradient = when { + !enabled -> emptyList() + hovered && hoverGradient.size >= 2 -> hoverGradient + accented -> accentGradient + else -> emptyList() + } + val containerColor = when { + backgroundGradient.isNotEmpty() -> Color.Transparent + selected -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.surfaceVariant + } + val contentColor = when { + hovered && enabled && hoverGradient.isNotEmpty() -> Color.Black + accented -> Color(0xFF395306) + selected -> MaterialTheme.colorScheme.onPrimary + else -> MaterialTheme.colorScheme.onSurfaceVariant + } CompositionLocalProvider( LocalMinimumInteractiveComponentSize provides 0.dp @@ -458,35 +584,28 @@ object Form { onClick = onClick, enabled = enabled, shape = Density.BUTTON_SHAPE, - contentPadding = Density.BUTTON_CONTENT_PADDING, + contentPadding = PaddingValues(horizontal = horizontalPadding, vertical = 0.dp), 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, + border = when { + pressed -> BorderStroke(Density.SELECTION_BORDER, palette.buttonPressedBorder) + enabled -> BorderStroke(Density.HAIRLINE, MaterialTheme.colorScheme.outline) + else -> BorderStroke( + Density.HAIRLINE, + MaterialTheme.colorScheme.outline.copy(alpha = 0.38f), ) }, - modifier = Modifier - .heightIn(min = Density.BUTTON_MIN_HEIGHT) + colors = ButtonDefaults.buttonColors( + containerColor = containerColor, + contentColor = contentColor, + disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.38f), + disabledContentColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f), + ), + modifier = modifier + .heightIn(min = Density.CONTROL_HEIGHT) .then( - if (hovered && enabled && hoverGradient.size >= 2) { + if (backgroundGradient.size >= 2) { Modifier.background( - brush = Brush.verticalGradient(hoverGradient), + brush = Brush.verticalGradient(backgroundGradient), shape = Density.BUTTON_SHAPE, ) } else { @@ -499,7 +618,11 @@ object Form { * 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) + Text( + text, + style = compactControlTextStyle(), + maxLines = 1, + ) } } } @@ -538,6 +661,138 @@ object Form { } } +/** + * A content-sized toggle with the same typography, height and palette as the shared + * desktop buttons. Checkbox semantics keep keyboard and accessibility behaviour while + * avoiding the extra symbol and label width of a checkbox row. + */ +@Composable +internal fun CompactToggleButton( + text: String, + checked: Boolean, + enabled: Boolean = true, + modifier: Modifier = Modifier, + onCheckedChange: (Boolean) -> Unit, +) { + val interactions = remember { MutableInteractionSource() } + val hovered by interactions.collectIsHoveredAsState() + val focused by interactions.collectIsFocusedAsState() + val pressed by interactions.collectIsPressedAsState() + val containerColor = when { + !enabled -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.38f) + pressed -> MaterialTheme.colorScheme.primaryContainer + hovered && checked -> MaterialTheme.colorScheme.primaryContainer + checked -> MaterialTheme.colorScheme.primary + hovered || focused -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.72f) + else -> MaterialTheme.colorScheme.surfaceVariant + } + + val contentColor = when { + !enabled -> MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f) + (pressed || hovered) && checked -> MaterialTheme.colorScheme.onPrimaryContainer + checked -> MaterialTheme.colorScheme.onPrimary + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + val outlineColor = when { + focused || checked -> MaterialTheme.colorScheme.primary + enabled -> MaterialTheme.colorScheme.outline + else -> MaterialTheme.colorScheme.outline.copy(alpha = 0.38f) + } + + Box( + modifier = modifier + .heightIn(min = Density.CONTROL_HEIGHT) + .background(containerColor, Density.BUTTON_SHAPE) + .border( + if (focused) Density.SELECTION_BORDER else Density.HAIRLINE, + outlineColor, + Density.BUTTON_SHAPE, + ) + .toggleable( + value = checked, + enabled = enabled, + role = Role.Checkbox, + interactionSource = interactions, + indication = null, + onValueChange = onCheckedChange, + ) + .padding(horizontal = 6.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = text, + style = compactControlTextStyle(), + color = contentColor, + maxLines = 1, + ) + } +} + +/** + * A compact desktop tab with the same interaction palette as the shared controls. + * The selected surface visually continues into the content below it, while the border + * keeps inactive entries recognizable as tabs in both palettes. + */ +@Composable +internal fun CompactTab( + text: String, + selected: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + val interactions = remember { MutableInteractionSource() } + val hovered by interactions.collectIsHoveredAsState() + val focused by interactions.collectIsFocusedAsState() + val pressed by interactions.collectIsPressedAsState() + val primary = MaterialTheme.colorScheme.primary + val background = when { + pressed -> MaterialTheme.colorScheme.primaryContainer + selected -> MaterialTheme.colorScheme.surface + hovered || focused -> MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.72f) + else -> MaterialTheme.colorScheme.surfaceVariant + } + val outline = when { + selected || focused -> primary + else -> MaterialTheme.colorScheme.outlineVariant + } + + Box( + modifier = modifier + .heightIn(min = Density.TAB_MIN_HEIGHT) + .background(background) + .border( + if (focused) Density.SELECTION_BORDER else Density.HAIRLINE, + outline, + ) + .selectable( + selected = selected, + interactionSource = interactions, + indication = null, + role = Role.Tab, + onClick = onClick, + ) + .drawBehind { + if (selected) { + val thickness = 2.dp.toPx() + drawRect( + color = primary, + topLeft = Offset(0f, size.height - thickness), + size = Size(size.width, thickness), + ) + } + } + .padding(horizontal = Density.TAB_HORIZONTAL_PADDING, vertical = 3.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text, + style = compactControlTextStyle(), + color = if (selected) primary else MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } +} + /** * A text field at desktop density. * @@ -563,6 +818,7 @@ internal fun CompactTextField( modifier: Modifier = Modifier, enabled: Boolean = true, isError: Boolean = false, + placeholder: String = "", visualTransformation: VisualTransformation = VisualTransformation.None, borderColor: Color? = null, borderThickness: Dp? = null, @@ -596,11 +852,11 @@ internal fun CompactTextField( onValueChange = onValueChange, enabled = enabled, singleLine = true, - textStyle = MaterialTheme.typography.bodyMedium.copy(color = textColor), + textStyle = compactControlTextStyle().copy(color = textColor), cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), visualTransformation = visualTransformation, interactionSource = interactionSource, - modifier = modifier.heightIn(min = Density.FIELD_MIN_HEIGHT), + modifier = modifier.heightIn(min = Density.CONTROL_HEIGHT), ) { innerTextField -> OutlinedTextFieldDefaults.DecorationBox( value = value, @@ -612,6 +868,19 @@ internal fun CompactTextField( isError = isError, colors = colors, contentPadding = Density.FIELD_CONTENT_PADDING, + placeholder = if (placeholder.isNotEmpty()) { + { + Text( + placeholder, + style = compactControlTextStyle().copy( + color = MaterialTheme.colorScheme.onSurfaceVariant, + ), + maxLines = 1, + ) + } + } else { + null + }, container = { OutlinedTextFieldDefaults.Container( enabled = enabled, @@ -628,16 +897,21 @@ internal fun CompactTextField( } /** - * A checkbox without the 48dp touch target Material reserves around it. On a desktop - * form that target is dead space between every two rows. + * A checkbox on the shared compact control surface instead of Material's 48dp touch target. */ @Composable -internal fun CompactCheckbox(checked: Boolean, onCheckedChange: (Boolean) -> Unit) { - CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { +internal fun CompactCheckbox( + checked: Boolean, + modifier: Modifier = Modifier, + enabled: Boolean = true, + onCheckedChange: (Boolean) -> Unit, +) { + CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) { Checkbox( checked = checked, + enabled = enabled, onCheckedChange = onCheckedChange, - modifier = Modifier.size(Density.CHECK_SIZE), + modifier = modifier.size(Density.CONTROL_HEIGHT), ) } } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindow.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindow.kt index 66731a85..a3542649 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindow.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindow.kt @@ -9,8 +9,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.unit.dp import androidx.compose.ui.window.FrameWindowScope -import kst4contest.model.Band import kst4contest.model.ChatMember import kst4contest.model.ChatMessage import kst4contest.utils.PlatformUtils @@ -31,7 +31,6 @@ import java.util.function.Consumer fun FrameWindowScope.MainWindow( state: MainWindowState, actions: MainMenuActions, - skedBands: List, openInBrowser: Consumer, onShowOnMap: () -> Unit, onShowPathInAirScout: () -> Unit, @@ -42,6 +41,7 @@ fun FrameWindowScope.MainWindow( * message aims at whoever it was addressed to rather than at oneself. */ onDirectedMessageClicked: (ChatMessage) -> Unit, + onMessageStationClicked: (ChatMessage, StationSelectionSource) -> Unit, /** Whether a message came from this station; decides its row colour. */ isOwnMessage: (ChatMessage) -> Boolean, /** How old a message is, for the fading highlight. */ @@ -133,6 +133,7 @@ fun FrameWindowScope.MainWindow( state = state, onCandidateClicked = onCandidateClicked, onDirectedMessageClicked = onDirectedMessageClicked, + onMessageStationClicked = onMessageStationClicked, isOwnMessage = isOwnMessage, messageAgeSeconds = messageAgeSeconds, ) @@ -140,7 +141,6 @@ fun FrameWindowScope.MainWindow( { StationColumn( state = state, - skedBands = skedBands, openInBrowser = openInBrowser, onShowOnMap = onShowOnMap, onShowPathInAirScout = onShowPathInAirScout, @@ -167,6 +167,7 @@ private fun MessageColumn( state: MainWindowState, onCandidateClicked: (TimelineCandidate) -> Unit, onDirectedMessageClicked: (ChatMessage) -> Unit, + onMessageStationClicked: (ChatMessage, StationSelectionSource) -> Unit, isOwnMessage: (ChatMessage) -> Boolean, messageAgeSeconds: (ChatMessage) -> Long, ) { @@ -174,6 +175,13 @@ private fun MessageColumn( state = state.messageSplitter, orientation = SplitterOrientation.VERTICAL, modifier = Modifier.fillMaxSize(), + minimumPaneSizes = listOf( + SplitterState.DEFAULT_MIN_PANE_PX.dp, + Density.CONTROL_HEIGHT, + TimelineGeometry.HEIGHT.dp, + Density.CONTROL_HEIGHT, + SplitterState.DEFAULT_MIN_PANE_PX.dp, + ), panes = listOf( { SnippetContextMenu(state.snippets.entries, state.chatInput) { @@ -216,6 +224,7 @@ private fun MessageColumn( publicMessages = state.publicMessages, clusterMessages = state.clusterMessages, qsoOfTheOther = state.qsoOfTheOther, + onStationMessageClicked = onMessageStationClicked, modifier = Modifier.fillMaxSize(), ) } @@ -230,7 +239,6 @@ private fun MessageColumn( @Composable private fun StationColumn( state: MainWindowState, - skedBands: List, openInBrowser: Consumer, onShowOnMap: () -> Unit, onShowPathInAirScout: () -> Unit, @@ -238,6 +246,10 @@ private fun StationColumn( onFiltersChanged: () -> Unit, onShowMorePriorities: () -> Unit, ) { + val stationContextMenu = remember(state) { + StationContextMenuState(state::selectStation) + } + SplitterPane( state = state.rightSplitter, orientation = SplitterOrientation.VERTICAL, @@ -248,24 +260,37 @@ private fun StationColumn( StationFilterBar( filters = state.stationFilter, activeBands = state.activeBands, + reachability = state.reachability, onChanged = { onFiltersChanged() }, + onQtfDirectionChanged = state::applyExplicitQtfFilterDirection, + onReset = state::resetStationFilters, modifier = Modifier.fillMaxWidth(), ) HorizontalDivider(thickness = Density.HAIRLINE) - SnippetContextMenu(state.snippets.entries, state.chatInput) { - DataTableView( - state = state.stations, - modifier = Modifier.fillMaxSize(), - onRowClick = onStationSelected, - ) - } + DataTableView( + state = state.stations, + modifier = Modifier.fillMaxSize(), + emphasizeCell = { member, column -> + column.id == "callsign" && state.newJoined.isRecent(member) + }, + onRowClick = onStationSelected, + onRowContextMenu = { member, position -> + stationContextMenu.open( + listOf(member), + StationSelectionSource.STATION_CONTEXT_MENU, + position, + ) + }, + selectOnRowClick = false, + ) } }, { TopPriorityBar( state = state.topPriority, + newJoined = state.newJoined, onShowMore = onShowMorePriorities, modifier = Modifier.fillMaxWidth(), ) @@ -274,8 +299,8 @@ private fun StationColumn( SelectedStationPanel( state = state.selectedStation, messages = state.selectedStationMessages, - skedBands = skedBands, activeBands = state.activeBands, + contentSplitter = state.selectedStationSplitter, openInBrowser = openInBrowser, onShowOnMap = onShowOnMap, onShowPathInAirScout = onShowPathInAirScout, @@ -284,4 +309,5 @@ private fun StationColumn( }, ), ) + StationContextOverlays(stationContextMenu, state, onShowOnMap) } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowHost.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowHost.kt index 1196ed98..55dc8975 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowHost.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowHost.kt @@ -1,6 +1,5 @@ package kst4contest.view.compose -import kst4contest.model.Band import kst4contest.model.ChatMember import kst4contest.model.ChatMessage import java.util.function.Consumer @@ -34,12 +33,12 @@ object MainWindowHost { */ 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 currentOnMessageStationClicked by androidx.compose.runtime.mutableStateOf<((ChatMessage, StationSelectionSource) -> 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) @@ -79,12 +78,12 @@ object MainWindowHost { * leave the client running with the chat connected and nothing to see it in. */ onCloseRequest: () -> Unit, - skedBands: () -> List, openInBrowser: Consumer, onShowOnMap: () -> Unit, onShowPathInAirScout: () -> Unit, onStationSelected: (ChatMember) -> Unit, onDirectedMessageClicked: (ChatMessage) -> Unit, + onMessageStationClicked: (ChatMessage, StationSelectionSource) -> Unit, isOwnMessage: (ChatMessage) -> Boolean, messageAgeSeconds: (ChatMessage) -> Long, onFiltersChanged: () -> Unit, @@ -93,12 +92,12 @@ object MainWindowHost { ) { currentState = state currentActions = actions - currentSkedBands = skedBands currentOpenInBrowser = openInBrowser currentOnShowOnMap = onShowOnMap currentOnShowPathInAirScout = onShowPathInAirScout currentOnStationSelected = onStationSelected currentOnDirectedMessageClicked = onDirectedMessageClicked + currentOnMessageStationClicked = onMessageStationClicked currentIsOwnMessage = isOwnMessage currentMessageAgeSeconds = messageAgeSeconds currentOnFiltersChanged = onFiltersChanged @@ -151,12 +150,12 @@ object MainWindowHost { MainWindow( state = s, actions = a, - skedBands = currentSkedBands?.invoke() ?: emptyList(), openInBrowser = currentOpenInBrowser ?: Consumer { _ -> }, onShowOnMap = currentOnShowOnMap ?: {}, onShowPathInAirScout = currentOnShowPathInAirScout ?: {}, onStationSelected = currentOnStationSelected ?: {}, onDirectedMessageClicked = currentOnDirectedMessageClicked ?: {}, + onMessageStationClicked = currentOnMessageStationClicked ?: { _, _ -> }, isOwnMessage = currentIsOwnMessage ?: { false }, messageAgeSeconds = currentMessageAgeSeconds ?: { 0L }, onFiltersChanged = currentOnFiltersChanged ?: {}, diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowState.kt index 3411e14f..b6d27ce4 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowState.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MainWindowState.kt @@ -55,19 +55,68 @@ class MainWindowState( val bandUpgradeNotice: BlinkingNotice, val threadButtons: ThreadStatusButtons, - /** The three splitters, whose positions the operator has arranged and stored. */ + /** The splitters whose positions the operator has arranged and stored. */ val outerSplitter: SplitterState, val messageSplitter: SplitterState, val rightSplitter: SplitterState, + val selectedStationSplitter: SplitterState = SplitterState( + SELECTED_STATION_PANE_COUNT, + doubleArrayOf(0.55), + ) {}, + val newJoined: NewJoinedState = NewJoinedState(onSelect = {}), + val reachability: ReachabilityState = ReachabilityState(activeBands), ) { + /** Explicit map analysis requested from either station context menu. */ + val mapReachability = MapReachabilityState(prefs.stn_loginLocatorMainCat.orEmpty()) + /** The shortcut buttons above the send line. */ /** * The one path that selects a station for the whole application (panel, table, score * service, send line). Set by the host after construction; other windows, such as the * map, go through it instead of touching [selectedStation] alone. Null clears. */ - var stationSelector: ((ChatMember?) -> Unit)? = null + var stationSelector: ((ChatMember?, StationSelectionSource) -> Unit)? = null + + /** Application callback that rebuilds the controller and Compose station filters. */ + var stationFiltersChanged: (() -> Unit)? = null + + /** Debounced persistence hook shared by the main and map windows. */ + var layoutSaveRequested: (() -> Unit)? = null + + /** Contactinfo-equivalent manual Worked action supplied by the controller host. */ + var markStationWorked: ((ChatMember, kst4contest.model.Band) -> Boolean)? = null + + /** Persistent any-band Worked gross fields used by the optional map overlay. */ + var workedGrossFieldsProvider: (() -> Set) = { emptySet() } + + val stationSelection: StationSelectionState + get() = selectedStation.stationSelection + + /** Routes every explicit station choice through the application-level action. */ + fun selectStation(member: ChatMember?, source: StationSelectionSource) { + mapReachability.selectionChanged(StationIdentity.of(member)) + val selector = stationSelector + if (selector != null) { + selector(member, source) + } else { + selectedStation.select(member, source) + } + } + + fun resetStationFilters() { + stationFilter.reset() + stationFiltersChanged?.invoke() + } + + /** Applies only a deliberate QTF filter entry, never a restored or reset filter value. */ + fun applyExplicitQtfFilterDirection(qtf: Double) { + applyExplicitQtfHeading(prefs, qtf) + } + + fun requestLayoutSave() { + layoutSaveRequested?.invoke() + } val shortcuts = RosterMirror(prefs.getLst_txtShortCutBtnList()) @@ -103,6 +152,9 @@ class MainWindowState( /** Station list, top priorities, selected station. */ const val RIGHT_PANE_COUNT = 3 + + /** Selected-station message table and its scrollable action area. */ + const val SELECTED_STATION_PANE_COUNT = 2 } } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/ManualQtfHeadingSynchronizer.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/ManualQtfHeadingSynchronizer.kt new file mode 100644 index 00000000..27532bdb --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/ManualQtfHeadingSynchronizer.kt @@ -0,0 +1,19 @@ +package kst4contest.view.compose + +import kst4contest.model.ChatPreferences + +private const val MIN_QTF = 0.0 +private const val MAX_QTF = 360.0 + +/** + * Applies a deliberate filter direction to the runtime antenna heading without sending a rotor command. + * + * @return true when the runtime heading was updated + */ +internal fun applyExplicitQtfHeading(prefs: ChatPreferences, qtf: Double): Boolean { + if (!qtf.isFinite() || qtf !in MIN_QTF..MAX_QTF) return false + if (!prefs.isQtfFilterDirectionAsAntennaHeadingEnabled) return false + + prefs.actualQTF.set(qtf) + return true +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MapReachabilityState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MapReachabilityState.kt new file mode 100644 index 00000000..3bf2b2cb --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MapReachabilityState.kt @@ -0,0 +1,83 @@ +package kst4contest.view.compose + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import kst4contest.model.Band +import kst4contest.model.ChatMember +import kst4contest.view.map.PathAnalysisResult + +/** + * Explicit map reachability request shared by station-list and map context menus. + * A monotonically increasing request ID prevents older callbacks from replacing a + * result for a newer station or band. + */ +class MapReachabilityState( + private val ownLocator: String, + requester: (ChatMember, Band, (PathAnalysisResult) -> Unit) -> Unit = { _, _, _ -> }, +) { + var requester: (ChatMember, Band, (PathAnalysisResult) -> Unit) -> Unit = requester + + var result: PathAnalysisResult? by mutableStateOf(null) + private set + + var running: Boolean by mutableStateOf(false) + private set + + var requestRevision: Long by mutableStateOf(0L) + private set + + var targetIdentity: StationIdentity? by mutableStateOf(null) + private set + + var requestedBand: Band? by mutableStateOf(null) + private set + + fun calculate(member: ChatMember?, band: Band?): Boolean { + if (member == null || band == null) return false + val identity = StationIdentity.of(member) ?: return false + val requestId = ++requestRevision + targetIdentity = identity + requestedBand = band + running = true + result = PathAnalysisResult.loading(ownLocator, member.qra, member.callSignRaw) + + try { + requester(member, band) { completed -> + if (requestRevision == requestId + && targetIdentity == identity + && requestedBand == band + ) { + result = completed + running = false + } + } + } catch (exception: RuntimeException) { + if (requestRevision == requestId) { + result = PathAnalysisResult.noProfile( + "Failed", + ownLocator, + member.qra, + member.callSignRaw, + Double.NaN, + member.getQTFdirection() ?: Double.NaN, + Double.NaN, + Double.NaN, + band.defaultAnalysisFrequencyMHz, + exception.message ?: "Reachability calculation failed.", + ) + running = false + } + } + return true + } + + fun selectionChanged(identity: StationIdentity?) { + if (targetIdentity == null || targetIdentity == identity) return + requestRevision++ + targetIdentity = null + requestedBand = null + result = null + running = false + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/MessageTabs.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/MessageTabs.kt index 49f65ecf..f488d062 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/MessageTabs.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/MessageTabs.kt @@ -2,13 +2,11 @@ package kst4contest.view.compose import kst4contest.view.i18n.LocalStrings 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 @@ -18,13 +16,8 @@ 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 androidx.compose.foundation.border import kst4contest.model.ChatMessage import kst4contest.model.ClusterMessage @@ -40,6 +33,7 @@ fun MessageTabs( publicMessages: DataTableState, clusterMessages: DataTableState, qsoOfTheOther: DataTableState, + onStationMessageClicked: (ChatMessage, StationSelectionSource) -> Unit = { _, _ -> }, modifier: Modifier = Modifier, ) { var selected by remember { mutableStateOf(0) } @@ -51,35 +45,20 @@ fun MessageTabs( strings.messagesQsoOfTheOther, ) - Column(modifier = modifier.fillMaxSize()) { + Column( + modifier = modifier + .fillMaxSize() + .border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant), + ) { 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) - } + CompactTab( + text = title, + selected = isSelected, + onClick = { selected = index }, + ) } } @@ -87,9 +66,21 @@ fun MessageTabs( Box(modifier = Modifier.fillMaxSize()) { when (selected) { - 0 -> DataTableView(publicMessages, modifier = Modifier.fillMaxSize()) + 0 -> DataTableView( + publicMessages, + modifier = Modifier.fillMaxSize(), + onRowClick = { message -> + onStationMessageClicked(message, StationSelectionSource.PUBLIC_MESSAGE) + }, + ) 1 -> DataTableView(clusterMessages, modifier = Modifier.fillMaxSize()) - else -> DataTableView(qsoOfTheOther, modifier = Modifier.fillMaxSize()) + else -> DataTableView( + qsoOfTheOther, + modifier = Modifier.fillMaxSize(), + onRowClick = { message -> + onStationMessageClicked(message, StationSelectionSource.QSO_OF_THE_OTHER) + }, + ) } } } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/NewJoinedState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/NewJoinedState.kt new file mode 100644 index 00000000..9bda87a3 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/NewJoinedState.kt @@ -0,0 +1,100 @@ +package kst4contest.view.compose + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import kst4contest.model.ChatMember +import kst4contest.model.RecentChatJoin + +/** One selectable station in the transient New joined group. */ +class NewJoinedEntry internal constructor( + val identity: StationIdentity, + val label: String, + val joinedAtEpochMillis: Long, + private val member: ChatMember, + private val onSelect: (ChatMember) -> Unit, +) { + fun select() = onSelect(member) +} + +/** + * Compose-facing view of live ON4KST join events. + * + * Initial and resynchronized user lists never reach this state. It receives the core's + * live-event roster and intersects it with the currently active station roster, so a + * departed station disappears immediately. The clock is injected to make expiry exact + * and testable without waiting ten minutes. + */ +class NewJoinedState( + private val clock: () -> Long = System::currentTimeMillis, + private val onSelect: (ChatMember) -> Unit, +) { + + private var events: List = emptyList() + private var activeMembers: List = emptyList() + private var liveEntries by mutableStateOf>(emptyList()) + + val entries: List + get() = liveEntries + + val visibleEntries: List + get() = liveEntries.take(SHOWN) + + val overflowEntries: List + get() = liveEntries.drop(SHOWN) + + fun updateEvents(newEvents: List) { + events = newEvents.toList() + refresh() + } + + fun updateActiveMembers(newMembers: List) { + activeMembers = newMembers.toList() + refresh() + } + + /** Removes expired or departed entries and resolves each event to the latest row instance. */ + fun refresh() { + val now = clock() + val activeByIdentity = activeMembers.mapNotNull { member -> + StationIdentity.of(member)?.let { identity -> identity to member } + }.toMap() + + val firstJoinByIdentity = linkedMapOf() + events.forEach { event -> + val identity = StationIdentity.of(event.callSign, event.categoryNumber) + ?: return@forEach + val existing = firstJoinByIdentity[identity] + if (existing == null || event.joinedAtEpochMillis < existing.joinedAtEpochMillis) { + firstJoinByIdentity[identity] = event + } + } + + liveEntries = firstJoinByIdentity + .mapNotNull { (identity, event) -> + val active = activeByIdentity[identity] ?: return@mapNotNull null + val ageMillis = now - event.joinedAtEpochMillis + if (ageMillis < 0L || ageMillis >= VALIDITY_MILLIS) { + return@mapNotNull null + } + NewJoinedEntry( + identity = identity, + label = identity.callSign, + joinedAtEpochMillis = event.joinedAtEpochMillis, + member = active, + onSelect = onSelect, + ) + } + .sortedByDescending(NewJoinedEntry::joinedAtEpochMillis) + } + + fun isRecent(member: ChatMember): Boolean { + val identity = StationIdentity.of(member) ?: return false + return liveEntries.any { it.identity == identity } + } + + companion object { + const val VALIDITY_MILLIS = 10L * 60L * 1000L + const val SHOWN = 5 + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/ReachabilityState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/ReachabilityState.kt new file mode 100644 index 00000000..3ca6fb08 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/ReachabilityState.kt @@ -0,0 +1,58 @@ +package kst4contest.view.compose + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import kst4contest.model.Band +import kst4contest.model.ChatMember + +/** One selectable entry in the reachability-band control. A null band means Auto. */ +data class ReachabilityBandOption(val band: Band?, val label: String) + +/** + * Operator-controlled reachability selection and the one explicit calculation action. + * + * Band selection only invalidates cached table/filter presentation. Online terrain work + * stays behind [calculateSelected], so recomposition and sorting cannot consume API calls. + */ +class ReachabilityState( + activeBands: Set, + private val resolveAutoBand: (ChatMember) -> Band? = { null }, + private val selectedStation: () -> ChatMember? = { null }, + private val calculate: (ChatMember, Band) -> Unit = { _, _ -> }, + private val refresh: () -> Unit = { }, +) { + private val supportedBands = activeBands.toSet() + + val options: List = + listOf(ReachabilityBandOption(null, "Auto")) + + activeBands.sortedBy(Band::ordinal).map { band -> + ReachabilityBandOption(band, band.displayLabel) + } + + var bandOverride: Band? by mutableStateOf(null) + private set + + val selectedOption: ReachabilityBandOption + get() = options.first { it.band == bandOverride } + + val canCalculate: Boolean + get() = selectedStation()?.let(::resolveBand) != null + + fun selectBand(band: Band?) { + val accepted = band?.takeIf(supportedBands::contains) + if (bandOverride == accepted) return + bandOverride = accepted + refresh() + } + + fun resolveBand(member: ChatMember): Band? = bandOverride ?: resolveAutoBand(member) + + /** Returns whether a complete station/band request was handed to the service. */ + fun calculateSelected(): Boolean { + val member = selectedStation() ?: return false + val band = resolveBand(member) ?: return false + calculate(member, band) + return true + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationMessagePredicate.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationMessagePredicate.kt new file mode 100644 index 00000000..9fcc5ab5 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationMessagePredicate.kt @@ -0,0 +1,60 @@ +package kst4contest.view.compose + +import kst4contest.model.ChatMember +import kst4contest.model.ChatMessage +import java.util.Locale +import java.util.function.Predicate + +/** Builds the selected-station message filter without changing the source history. */ +object SelectedStationMessagePredicate { + + @JvmStatic + fun forSelection( + filter: SelectedMessageFilter, + selected: ChatMember?, + ownCallSign: String?, + ): Predicate { + val selectedIdentity = StationIdentity.of(selected) + val ownCall = ownCallSign.normalizedCall() + + return Predicate { message -> + if (selectedIdentity?.categoryNumber == null) { + return@Predicate false + } + + val senderCall = message?.sender?.callSign.normalizedCall() + val receiverCall = message?.receiver?.callSign.normalizedCall() + val messageCategory = message?.chatCategory?.categoryNumber + if (senderCall.isBlank() || receiverCall.isBlank() || + messageCategory != selectedIdentity.categoryNumber + ) { + return@Predicate false + } + + val selectedIsSender = senderCall == selectedIdentity.callSign + val selectedIsReceiver = receiverCall == selectedIdentity.callSign + val ownIsSender = senderCall == ownCall + val ownIsReceiver = receiverCall == ownCall + val isPublic = message.isMessageDirectedToCommunity || receiverCall == PUBLIC_RECEIVER + + when (filter) { + SelectedMessageFilter.PM_TO_ME -> + ownCall.isNotBlank() && ( + (!isPublic && ((selectedIsSender && ownIsReceiver) || + (ownIsSender && selectedIsReceiver))) || + (isPublic && selectedIsSender && message.isMessageDirectedToMe) + ) + + SelectedMessageFilter.PM_TO_OTHER -> + ownCall.isNotBlank() && selectedIsSender && !isPublic && !ownIsReceiver + + SelectedMessageFilter.PUBLIC -> selectedIsSender && isPublic + SelectedMessageFilter.NOTHING -> selectedIsSender || selectedIsReceiver + } + } + } + + private fun String?.normalizedCall(): String = this?.trim()?.uppercase(Locale.ROOT).orEmpty() + + private const val PUBLIC_RECEIVER = "ALL" +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationPanel.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationPanel.kt index 4597521d..cffb8905 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationPanel.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationPanel.kt @@ -2,15 +2,36 @@ package kst4contest.view.compose import kst4contest.view.i18n.LocalStrings import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.rememberScrollbarAdapter +import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.VerticalScrollbar +import androidx.compose.foundation.LocalScrollbarStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.foundation.selection.selectable import androidx.compose.material3.ExperimentalMaterial3Api @@ -22,8 +43,26 @@ 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 androidx.compose.ui.platform.testTag import kst4contest.model.Band import java.util.function.Consumer +import kotlinx.coroutines.delay + +internal const val SELECTED_STATION_PANEL_TAG = "selected-station-panel" +internal const val SELECTED_MESSAGES_TABLE_TAG = "selected-messages-table" +internal const val SELECTED_NOT_QRV_GROUP_TAG = "selected-not-qrv-group" +internal const val SELECTED_SKED_GROUP_TAG = "selected-sked-group" +internal const val NEW_JOINED_MORE_TAG = "new-joined-more" +internal const val NEW_JOINED_GROUP_TAG = "new-joined-group" +internal const val PRIORITY_GROUP_TAG = "priority-group" + +internal fun skedBandOptions(activeBands: Set): List { + val options = ArrayList(activeBands.size) + Band.entries.forEach { band -> + if (activeBands.contains(band)) options.add(band) + } + return options +} /** * The panel below the station list, for whichever station is selected. @@ -40,10 +79,9 @@ import java.util.function.Consumer 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. */ + /** My own station's enabled bands; used for NOT-QRV and sked controls. */ activeBands: Set, + contentSplitter: SplitterState? = null, openInBrowser: Consumer, onShowOnMap: () -> Unit, onShowPathInAirScout: () -> Unit, @@ -51,136 +89,183 @@ fun SelectedStationPanel( ) { val strings = LocalStrings.current val member = state.selected + val splitter = contentSplitter ?: remember { + SplitterState(2, doubleArrayOf(DEFAULT_SELECTED_MESSAGE_FRACTION)) {} + } - Column( - modifier = modifier.fillMaxWidth().padding(4.dp), - verticalArrangement = Arrangement.spacedBy(3.dp), + Box( + modifier = modifier + .fillMaxWidth() + .testTag(SELECTED_STATION_PANEL_TAG) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.22f)) + .border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant), ) { + Column(modifier = Modifier.fillMaxSize()) { if (member == null) { Text( strings.selectedNone, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.outline, + modifier = Modifier.padding(4.dp), ) return@Column } - Text( - member.callSign.orEmpty() + " " + member.qra.orEmpty(), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, + SelectedStationHeaderAndFilter(state, member) + + SplitterPane( + state = splitter, + orientation = SplitterOrientation.VERTICAL, + modifier = Modifier.fillMaxWidth().weight(1f), + minimumPaneSizes = listOf( + Density.ROW_MIN_HEIGHT * 2, + Density.SELECTED_ACTION_AREA_MIN_HEIGHT, + ), + panes = listOf( + { + DataTableView( + state = messages, + modifier = Modifier.fillMaxSize().testTag(SELECTED_MESSAGES_TABLE_TAG), + ) + }, + { + SelectedStationFunctions( + state = state, + activeBands = activeBands, + openInBrowser = openInBrowser, + onShowOnMap = onShowOnMap, + onShowPathInAirScout = onShowPathInAirScout, + ) + }, + ), ) + } + } +} - FlowRow( - 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) - } - +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun SelectedStationHeaderAndFilter( + state: SelectedStationState, + member: kst4contest.model.ChatMember, +) { + val strings = LocalStrings.current + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 2.dp), + verticalArrangement = Arrangement.spacedBy(1.dp), + ) { + val category = member.chatCategory?.let { it.getChatCategoryName(it.categoryNumber) } + val detectedBands = state.detectedBands() + val metadata = buildList { + category?.let(::add) + member.getQTFdirection()?.let { add("QTF: $it°") } + member.qrb?.let { add("QRB: $it km") } val lastActSec = member.activityTimeLastInEpoch if (lastActSec > 0L) { val actStr = MessageFormats.clockTime(lastActSec.toString()) - val minAgo = kst4contest.controller.Utils4KST.time_getSecondsBetweenEpochAndNow(lastActSec.toString()) / 60 - Text(strings.selectedLastActivity(actStr, minAgo), style = MaterialTheme.typography.bodySmall) - } - - val detectedBands = state.detectedBands() - if (detectedBands.isNotBlank()) { - Text(detectedBands, style = MaterialTheme.typography.bodySmall) + val minAgo = kst4contest.controller.Utils4KST + .time_getSecondsBetweenEpochAndNow(lastActSec.toString()) / 60 + add(strings.selectedLastActivity(actStr, minAgo)) } + add(detectedBands.ifBlank { strings.tooltipBandsNone }) } - - Form.section(strings.selectedShowMessages) { - FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) { - SelectedMessageFilter.entries.forEach { filter -> - RadioChoice( - label = filter.label, - selected = state.messageFilter == filter, - onSelect = { state.messageFilter = filter }, + FlowRow( + horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP), + verticalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier.fillMaxWidth().padding(bottom = 1.dp), + ) { + Text( + member.callSign.orEmpty(), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + metadata.forEach { value -> + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "|", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline, + modifier = Modifier.padding(end = Density.BUTTON_GAP), ) + Text(value, style = MaterialTheme.typography.bodySmall) } } } - DataTableView( - state = messages, - modifier = Modifier.weight(1f).fillMaxWidth(), - ) - - Form.section(strings.selectedTagNotQrv) { - FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) { - val activeNotQrvBands = NotQrvBand.entries.filter { activeBands.contains(it.band) } - - activeNotQrvBands.forEach { band -> - Form.check(band.label, state.isTaggedNotQrv(band)) { - state.tagNotQrv(band, it) - } - } - - Form.check(strings.selectedNotQrvAll, activeNotQrvBands.all { state.isTaggedNotQrv(it) }) { - state.tagNotQrvAll(it) - } - } - } - - Form.section("Sked") { - FlowRow( - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP) + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP), + verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP), + itemVerticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.height(Density.CONTROL_HEIGHT), ) { - Form.inlineChoice( - label = strings.selectedSkedInMinutes, - items = SelectedStationState.SKED_MINUTES, - selected = state.skedMinutes, - describe = { it.toString() }, - onSelect = { state.skedMinutes = it }, + Text( + "${strings.selectedShowMessages}:", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(end = Density.BUTTON_GAP), ) - Form.inlineChoice( - label = strings.selectedBand, - items = skedBands, - selected = state.skedBand, - describe = { it.prefix + " MHz" }, - onSelect = { state.skedBand = it }, - ) - Form.inlineChoice( - label = strings.selectedMode, - items = SelectedStationState.SKED_MODES, - selected = state.skedMode, - describe = { it }, - onSelect = { state.skedMode = it }, + RadioChoice( + label = SelectedMessageFilter.PM_TO_ME.label, + selected = state.messageFilter == SelectedMessageFilter.PM_TO_ME, + onSelect = { state.messageFilter = SelectedMessageFilter.PM_TO_ME }, ) } - - Row(horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) { - Form.check(strings.selectedRemindPmIn, state.remindPm) { state.remindPm = it } - Form.inlineChoice( - label = strings.selectedReminderOffsets, - items = SelectedStationState.REMINDER_OFFSETS, - selected = state.reminderOffsets, - describe = { it }, - enabled = state.remindPm, - onSelect = { state.reminderOffsets = it }, + SelectedMessageFilter.entries.drop(1).forEach { filter -> + RadioChoice( + label = filter.label, + selected = state.messageFilter == filter, + onSelect = { state.messageFilter = filter }, ) } - - FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) { - Form.button(strings.selectedCreateSked, enabled = state.canCreateSked) { state.createSked() } - Form.button(strings.selectedSkedFail, enabled = state.canCreateSked) { state.markSkedFail() } - Form.button(strings.selectedResetFail, enabled = state.canCreateSked) { state.resetSkedFail() } - } } + } +} - Form.section(strings.selectedLookUpAndShow) { +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun SelectedStationFunctions( + state: SelectedStationState, + activeBands: Set, + openInBrowser: Consumer, + onShowOnMap: () -> Unit, + onShowPathInAirScout: () -> Unit, +) { + val strings = LocalStrings.current + val scroll = rememberScrollState() + Column( + modifier = Modifier.fillMaxSize().padding(start = 4.dp, top = 1.dp, bottom = 2.dp), + verticalArrangement = Arrangement.spacedBy(1.dp), + ) { + Box(modifier = Modifier.fillMaxWidth().weight(1f)) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(end = Density.SCROLLBAR_GUTTER) + .verticalScroll(scroll), + ) { + SelectedStationActionGroups( + state = state, + activeBands = activeBands, + ) + } + + VerticalScrollbar( + adapter = rememberScrollbarAdapter(scroll), + modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(), + style = LocalScrollbarStyle.current.copy( + unhoverColor = MaterialTheme.colorScheme.outline, + hoverColor = MaterialTheme.colorScheme.onSurface, + ), + ) + } + + CompactFunctionGroup( + title = strings.selectedLookUpAndShow, + modifier = Modifier.fillMaxWidth().padding(end = Density.SCROLLBAR_GUTTER), + ) { FlowRow(horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP)) { Form.button(strings.selectedShowPathInAs, onClick = onShowPathInAirScout) Form.button(strings.selectedShowOnMap, onClick = onShowOnMap) @@ -202,8 +287,207 @@ fun SelectedStationPanel( } } } + } +} +private const val DEFAULT_SELECTED_MESSAGE_FRACTION = 0.55 +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun SelectedStationActionGroups( + state: SelectedStationState, + activeBands: Set, +) { + val strings = LocalStrings.current + val member = state.selected ?: return + val activeNotQrvBands = NotQrvBand.entries.filter { activeBands.contains(it.band) } + val notQrvContent: @Composable () -> Unit = { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP), + verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP), + ) { + activeNotQrvBands.forEach { band -> + Form.toggle(band.label, state.isTaggedNotQrv(band)) { + state.tagNotQrv(band, it) + } + } + Form.toggle( + strings.selectedNotQrvAll, + activeNotQrvBands.all { state.isTaggedNotQrv(it) }, + ) { + state.tagNotQrvAll(it) + } + } + } + val skedContent: @Composable () -> Unit = { + SkedAlertControls(state, activeBands) + } + + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + if (maxWidth >= Density.SELECTED_ACTION_GROUPS_ROW_MIN_WIDTH) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP), + verticalAlignment = Alignment.Top, + ) { + CompactFunctionGroup( + title = strings.selectedTagNotQrv(member.callSign.orEmpty()), + modifier = Modifier.weight(0.35f).testTag(SELECTED_NOT_QRV_GROUP_TAG), + content = notQrvContent, + ) + CompactFunctionGroup( + title = strings.selectedSked, + modifier = Modifier.weight(0.65f).testTag(SELECTED_SKED_GROUP_TAG), + content = skedContent, + ) + } + } else { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP), + ) { + CompactFunctionGroup( + title = strings.selectedTagNotQrv(member.callSign.orEmpty()), + modifier = Modifier.fillMaxWidth().testTag(SELECTED_NOT_QRV_GROUP_TAG), + content = notQrvContent, + ) + CompactFunctionGroup( + title = strings.selectedSked, + modifier = Modifier.fillMaxWidth().testTag(SELECTED_SKED_GROUP_TAG), + content = skedContent, + ) + } + } + } +} + +@Composable +private fun CompactFunctionGroup( + title: String, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + Column( + modifier = modifier + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.48f)) + .border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant, Density.BUTTON_SHAPE) + .padding(3.dp), + verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP), + ) { + Text( + title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + content() + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun SkedAlertControls(state: SelectedStationState, activeBands: Set) { + val strings = LocalStrings.current + SkedEditorControls( + activeBands = activeBands, + minutes = state.skedMinutes, + onMinutesChanged = { state.skedMinutes = it }, + band = state.skedBand, + onBandChanged = { state.skedBand = it }, + mode = state.skedMode, + onModeChanged = { state.skedMode = it }, + remindPm = state.remindPm, + onRemindPmChanged = { state.remindPm = it }, + reminderOffsets = state.reminderOffsets, + onReminderOffsetsChanged = { state.reminderOffsets = it }, + canCreate = state.canCreateSked, + onCreate = { state.createSked() }, + additionalActions = { + Form.button(strings.selectedSkedFail, enabled = state.canCreateSked) { state.markSkedFail() } + Form.button(strings.selectedResetFail, enabled = state.canCreateSked) { state.resetSkedFail() } + }, + ) +} + +/** Shared compact sked editor used by the station panel and the context popup. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun SkedEditorControls( + activeBands: Set, + minutes: Int, + onMinutesChanged: (Int) -> Unit, + band: Band?, + onBandChanged: (Band) -> Unit, + mode: String?, + onModeChanged: (String) -> Unit, + remindPm: Boolean, + onRemindPmChanged: (Boolean) -> Unit, + reminderOffsets: String, + onReminderOffsetsChanged: (String) -> Unit, + canCreate: Boolean, + onCreate: () -> Unit, + additionalActions: @Composable () -> Unit = {}, +) { + val strings = LocalStrings.current + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP), + verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP), + ) { + Form.inlineChoice( + label = strings.selectedSkedIn, + items = SelectedStationState.SKED_MINUTES, + selected = minutes, + describe = { it.toString() }, + trailingText = strings.selectedSkedMinutesAt, + onSelect = onMinutesChanged, + ) + Form.inlineChoice( + label = "", + items = skedBandOptions(activeBands), + selected = band, + describe = { it.prefix + " MHz" }, + trailingText = strings.selectedSkedModeLink, + onSelect = onBandChanged, + ) + Form.inlineChoice( + label = "", + items = SelectedStationState.SKED_MODES, + selected = mode, + describe = { it }, + trailingText = ".", + onSelect = onModeChanged, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.height(Density.CONTROL_HEIGHT), + ) { + CompactCheckbox(remindPm, onCheckedChange = onRemindPmChanged) + Text( + strings.selectedRemindPmIn, + style = compactControlTextStyle(), + modifier = Modifier.padding(start = 4.dp, end = 4.dp), + ) + Form.inlineChoice( + label = "", + items = SelectedStationState.REMINDER_OFFSETS, + selected = reminderOffsets, + describe = { it }, + enabled = remindPm, + trailingText = strings.selectedMinutes, + onSelect = onReminderOffsetsChanged, + ) + } + } + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP), + verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP), + ) { + Form.button(strings.selectedCreateSked, enabled = canCreate, onClick = onCreate) + additionalActions() } } @@ -214,26 +498,111 @@ fun SelectedStationPanel( @Composable fun TopPriorityBar( state: TopPriorityState, + newJoined: NewJoinedState? = null, onShowMore: () -> Unit, modifier: Modifier = Modifier, ) { val strings = LocalStrings.current - Row( - modifier = modifier.padding(2.dp), - horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP), - verticalAlignment = Alignment.CenterVertically, - ) { - Text(strings.selectedPriority, style = MaterialTheme.typography.bodySmall) - - state.entries.forEach { entry -> - Form.button(entry.label, onClick = entry.select) + LaunchedEffect(newJoined) { + while (newJoined != null) { + delay(NEW_JOINED_REFRESH_INTERVAL_MILLIS) + newJoined.refresh() } + } - Form.button(strings.selectedMore, onClick = onShowMore) + BoxWithConstraints(modifier = modifier) { + val allJoinedEntries = newJoined?.entries.orEmpty() + val directCount = when { + maxWidth >= 520.dp -> NewJoinedState.SHOWN + maxWidth >= 450.dp -> 4 + maxWidth >= 380.dp -> 3 + maxWidth >= 310.dp -> 2 + else -> 1 + } + val joinedEntries = allJoinedEntries.take(directCount) + val overflow = allJoinedEntries.drop(directCount) + + FlowRow( + modifier = Modifier.fillMaxWidth().padding(2.dp), + horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP), + verticalArrangement = Arrangement.spacedBy(2.dp), + itemVerticalAlignment = Alignment.CenterVertically, + ) { + if (joinedEntries.isNotEmpty()) { + CandidateGroup(Modifier.testTag(NEW_JOINED_GROUP_TAG)) { + Text(strings.selectedNewJoined, style = MaterialTheme.typography.bodySmall) + + joinedEntries.forEach { entry -> + Form.button(entry.label, horizontalPadding = 4.dp, onClick = entry::select) + } + + if (overflow.isNotEmpty()) { + NewJoinedOverflow(overflow) + } + } + } + + CandidateGroup(Modifier.testTag(PRIORITY_GROUP_TAG)) { + Text(strings.selectedPriority, style = MaterialTheme.typography.bodySmall) + + state.entries.forEach { entry -> + Form.button(entry.label, onClick = entry.select) + } + + Form.button(strings.selectedMore, onClick = onShowMore) + } + } } } +@Composable +private fun CandidateGroup(modifier: Modifier = Modifier, content: @Composable () -> Unit) { + FlowRow( + modifier = modifier + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)) + .border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant, Density.BUTTON_SHAPE) + .padding(2.dp), + horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP), + verticalArrangement = Arrangement.spacedBy(2.dp), + itemVerticalAlignment = Alignment.CenterVertically, + ) { + content() + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun NewJoinedOverflow(entries: List) { + val strings = LocalStrings.current + var expanded by remember { mutableStateOf(false) } + + Box { + Form.button( + strings.selectedMore, + modifier = Modifier.testTag(NEW_JOINED_MORE_TAG), + horizontalPadding = 4.dp, + ) { expanded = true } + CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) { + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + entries.forEach { entry -> + DropdownMenuItem( + text = { Text(entry.label, style = compactControlTextStyle()) }, + onClick = { + expanded = false + entry.select() + }, + contentPadding = PaddingValues(horizontal = 8.dp), + modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT), + ) + } + } + } + } +} + +private const val NEW_JOINED_REFRESH_INTERVAL_MILLIS = 1_000L + /** * One of a set of mutually exclusive choices. @@ -249,6 +618,7 @@ private fun RadioChoice(label: String, selected: Boolean, onSelect: () -> Unit) Row( modifier = Modifier .selectable(selected = selected, onClick = onSelect) + .height(Density.CONTROL_HEIGHT) .padding(end = Density.BUTTON_GAP), verticalAlignment = Alignment.CenterVertically, ) { diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationState.kt index 4e9894a0..8e90427a 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationState.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SelectedStationState.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import kst4contest.model.Band import kst4contest.model.ChatMember +import kst4contest.model.ChatPreferences /** * The bands a station can be tagged as not being on, under the labels the panel shows. @@ -39,7 +40,17 @@ enum class SelectedMessageFilter(val label: String) { PM_TO_ME("pm to me"), PM_TO_OTHER("pm to other"), PUBLIC("public msgs"), - NOTHING("nothing"), + NOTHING("nothing"); + + companion object { + @JvmStatic + fun fromPreferences(prefs: ChatPreferences): SelectedMessageFilter = when { + prefs.isGuiOptions_defaultFilterPmToMe -> PM_TO_ME + prefs.isGuiOptions_defaultFilterPmToOther -> PM_TO_OTHER + prefs.isGuiOptions_defaultFilterPublicMsgs -> PUBLIC + else -> NOTHING + } + } } /** @@ -61,7 +72,7 @@ enum class SelectedMessageFilter(val label: String) { * every draw, so the supplied lambda has to read Compose state for the button to * follow a settings Apply */ -class SelectedStationState( +class SelectedStationState @JvmOverloads constructor( private val propagateNotQrv: (ChatMember) -> Unit, private val createSked: (ChatMember, Int, Band?, String?) -> Boolean, private val markSkedFail: (ChatMember) -> Unit = { }, @@ -72,10 +83,14 @@ class SelectedStationState( private val turnAntennaTo: ((Double) -> Unit)? = null, private val turnSecondAntennaTo: ((Double) -> Unit)? = null, private val secondAntennaOffered: () -> Boolean = { false }, + val stationSelection: StationSelectionState = StationSelectionState(), ) { - var selected: ChatMember? by mutableStateOf(null) - private set + /** Shared sked action used by both the panel and isolated context-menu drafts. */ + var skedRequestSubmitter: ((SkedRequest) -> Boolean)? = null + + val selected: ChatMember? + get() = stationSelection.selected /** Bumped on a tag so the panel redraws; the flags live on the station itself. */ private var tagRevision: Int by mutableStateOf(0) @@ -136,25 +151,36 @@ class SelectedStationState( if (heading.isFinite() && showsSecondAntenna) turnSecondAntennaTo?.invoke(heading) } - fun select(member: ChatMember?) { - if (selected?.callSignRaw != member?.callSignRaw) { + @JvmOverloads + fun select( + member: ChatMember?, + source: StationSelectionSource = StationSelectionSource.PROGRAMMATIC, + ) { + if (StationIdentity.of(selected) != StationIdentity.of(member)) { skedBand = member?.let { resolveDefaultSkedBand(it) } } - selected = member + stationSelection.select(member, source) } fun isTaggedNotQrv(band: NotQrvBand): Boolean { @Suppress("UNUSED_EXPRESSION") tagRevision - val member = selected ?: return false + return selected?.let { isTaggedNotQrv(it, band) } ?: false + } + fun isTaggedNotQrv(member: ChatMember, band: NotQrvBand): Boolean { + @Suppress("UNUSED_EXPRESSION") tagRevision return !band.read(member) } /** Tagged means not QRV, so the station's QRV flag goes the other way. */ fun tagNotQrv(band: NotQrvBand, tagged: Boolean) { val member = selected ?: return + tagNotQrv(member, band, tagged) + } + /** Applies a context action to its fixed target instead of the current selection. */ + fun tagNotQrv(member: ChatMember, band: NotQrvBand, tagged: Boolean) { band.write(member, !tagged) tagRevision++ propagateNotQrv(member) @@ -162,7 +188,10 @@ class SelectedStationState( fun tagNotQrvAll(tagged: Boolean) { val member = selected ?: return + tagNotQrvAll(member, tagged) + } + fun tagNotQrvAll(member: ChatMember, tagged: Boolean) { NotQrvBand.entries.forEach { it.write(member, !tagged) } tagRevision++ propagateNotQrv(member) @@ -172,9 +201,33 @@ class SelectedStationState( fun createSked(): Boolean { val member = selected ?: return false - return createSked(member, skedMinutes, skedBand, skedMode) + val request = SkedRequest( + target = member, + minutes = skedMinutes, + band = skedBand, + mode = skedMode, + remindPm = remindPm, + reminderOffsets = reminderOffsets, + ) + return submitSked(request) } + /** Submits a fixed-target request through the shared application action. */ + fun submitSked(request: SkedRequest): Boolean = + skedRequestSubmitter?.invoke(request) + ?: createSked(request.target, request.minutes, request.band, request.mode) + + /** Copies the panel values into an isolated draft for a fixed target. */ + fun createSkedDraft(target: ChatMember): SkedEditorState = SkedEditorState( + target = target, + minutes = skedMinutes, + band = skedBand ?: resolveDefaultSkedBand(target), + mode = skedMode, + remindPm = remindPm, + reminderOffsets = reminderOffsets, + submitter = ::submitSked, + ) + /** * Marks the path to this station as failed. * diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsTabs.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsTabs.kt index c1ee1529..7dc0310f 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsTabs.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SettingsTabs.kt @@ -1,10 +1,7 @@ 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 @@ -324,18 +321,11 @@ fun settingsButtons( host: SettingsHost, notices: SettingsNotices, draft: SettingsDraft, + connectionState: () -> On4KstConnectionState, ): @Composable (close: () -> Unit) -> Unit { - val barState = ConnectionBarState(controller.chatPreferences, controller) + val barState = ConnectionBarState(controller.chatPreferences, connectionState) 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 = { @@ -343,14 +333,12 @@ fun settingsButtons( val refusal = host.connectFromSettings() if (refusal != null) notices.problem(refusal) } - revision++ }, onSave = { applyAndSave(draft, notices, host::savePreferencesFromSettings) }, onApplyAndClose = { if (applyChecked(draft, notices)) close() }, onDisconnectAndCloseChat = host::disconnectAndCloseChatFromSettings, onDisconnectOnly = { host.disconnectOnlyFromSettings() - revision++ }, ) } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SkedEditorState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SkedEditorState.kt new file mode 100644 index 00000000..c3f3b2a2 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SkedEditorState.kt @@ -0,0 +1,50 @@ +package kst4contest.view.compose + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import kst4contest.model.Band +import kst4contest.model.ChatMember + +/** Complete immutable request handed to the shared sked creation action. */ +data class SkedRequest( + val target: ChatMember, + val minutes: Int, + val band: Band?, + val mode: String?, + val remindPm: Boolean, + val reminderOffsets: String, +) + +/** + * Isolated editor draft used by the station-context popup. + * + * The target is captured when the popup opens. Later station selections therefore do + * not redirect a partially edited sked. + */ +class SkedEditorState( + val target: ChatMember, + minutes: Int, + band: Band?, + mode: String?, + remindPm: Boolean, + reminderOffsets: String, + private val submitter: (SkedRequest) -> Boolean, +) { + var minutes by mutableStateOf(minutes) + var band by mutableStateOf(band) + var mode by mutableStateOf(mode) + var remindPm by mutableStateOf(remindPm) + var reminderOffsets by mutableStateOf(reminderOffsets) + + fun request(): SkedRequest = SkedRequest( + target = target, + minutes = minutes, + band = band, + mode = mode, + remindPm = remindPm, + reminderOffsets = reminderOffsets, + ) + + fun submit(): Boolean = submitter(request()) +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SplitterPane.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SplitterPane.kt index 4a728496..924b2b98 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/SplitterPane.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SplitterPane.kt @@ -18,6 +18,7 @@ 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 androidx.compose.ui.unit.dp import java.awt.Cursor @@ -41,6 +42,7 @@ fun SplitterPane( orientation: SplitterOrientation, panes: List<@Composable () -> Unit>, modifier: Modifier = Modifier, + minimumPaneSizes: List = emptyList(), ) { BoxWithConstraints(modifier) { val density = LocalDensity.current @@ -55,7 +57,10 @@ fun SplitterPane( * 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 minimumPaneSizesPx = FloatArray(minimumPaneSizes.size) { index -> + with(density) { minimumPaneSizes[index].toPx() } + } + val sizes = state.paneSizes(totalPx, minimumPaneSizesPx) val body: @Composable () -> Unit = { panes.forEachIndexed { index, pane -> @@ -77,6 +82,7 @@ fun SplitterPane( index = index, orientation = orientation, totalPx = totalPx, + minimumPaneSizesPx = minimumPaneSizesPx, ) } } @@ -100,6 +106,7 @@ private fun SplitterHandle( index: Int, orientation: SplitterOrientation, totalPx: Float, + minimumPaneSizesPx: FloatArray, ) { val horizontal = orientation == SplitterOrientation.HORIZONTAL val cursor = if (horizontal) Cursor.E_RESIZE_CURSOR else Cursor.N_RESIZE_CURSOR @@ -111,15 +118,20 @@ private fun SplitterHandle( else Modifier.height(Density.COLUMN_HANDLE_WIDTH).fillMaxWidth(), ) .pointerHoverIcon(PointerIcon(Cursor(cursor))) - .pointerInput(index, totalPx) { + .pointerInput(index, totalPx, minimumPaneSizesPx.contentHashCode()) { 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) + val current = state.dividerPosition(index, totalPx, minimumPaneSizesPx) + state.drag( + index = index, + to = current + delta / totalPx, + totalPx = totalPx, + minimumPaneSizesPx = minimumPaneSizesPx, + ) } }, contentAlignment = Alignment.Center, diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/SplitterState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/SplitterState.kt index dd962c65..a233bf3e 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/SplitterState.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/SplitterState.kt @@ -39,9 +39,27 @@ class SplitterState( * @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) { + fun drag( + index: Int, + to: Float, + totalPx: Float, + minimumPaneSizesPx: FloatArray = EMPTY_MINIMUMS, + ) { if (index !in 0 until dividerCount) return + if (minimumPaneSizesPx.isNotEmpty()) { + val minimums = scaledMinimums(totalPx, minimumPaneSizesPx) + val effective = effectiveDividerPositions(totalPx, minimumPaneSizesPx) + val lowerBound = (effective.getOrElse(index - 1) { 0f } + minimums[index]) / totalPx + val upperBound = (effective.getOrElse(index + 1) { totalPx } - minimums[index + 1]) / totalPx + livePositions[index] = if (lowerBound > upperBound) { + (lowerBound + upperBound) / 2f + } else { + to.coerceIn(lowerBound, upperBound) + } + return + } + val minFraction = if (totalPx > 0f) minPanePx / totalPx else 0f val lowerBound = (if (index == 0) 0f else livePositions[index - 1]) + minFraction val upperBound = (if (index == dividerCount - 1) 1f else livePositions[index + 1]) - minFraction @@ -64,18 +82,71 @@ class SplitterState( } /** The pane lengths for a given splitter length; they always add up to the whole. */ - fun paneSizes(totalPx: Float): List { + fun paneSizes(totalPx: Float, minimumPaneSizesPx: FloatArray = EMPTY_MINIMUMS): List { if (paneCount <= 1) return listOf(totalPx) val edges = buildList { add(0f) - livePositions.forEach { add(it * totalPx) } + effectiveDividerPositions(totalPx, minimumPaneSizesPx).forEach { add(it) } add(totalPx) } return (0 until paneCount).map { edges[it + 1] - edges[it] } } + /** The position the pointer sees after pane-specific minimums have been applied. */ + internal fun dividerPosition( + index: Int, + totalPx: Float, + minimumPaneSizesPx: FloatArray = EMPTY_MINIMUMS, + ): Float = effectiveDividerPositions(totalPx, minimumPaneSizesPx) + .getOrElse(index) { 0f } / totalPx.coerceAtLeast(1f) + + private fun effectiveDividerPositions( + totalPx: Float, + minimumPaneSizesPx: FloatArray, + ): FloatArray { + if (minimumPaneSizesPx.isEmpty()) { + return FloatArray(dividerCount) { index -> livePositions[index] * totalPx } + } + + val minimums = scaledMinimums(totalPx, minimumPaneSizesPx) + val effective = FloatArray(dividerCount) + var previous = 0f + repeat(dividerCount) { index -> + val lowerBound = previous + minimums[index] + var trailingMinimum = 0f + for (remainingIndex in index + 1 until paneCount) { + trailingMinimum += minimums[remainingIndex] + } + val upperBound = totalPx - trailingMinimum + val wanted = livePositions[index] * totalPx + // Scaling impossible minimums can leave the two bounds a few float ULPs + // apart. Treat that rounding-only empty range like drag() does instead of + // letting coerceIn throw during layout. + val position = if (lowerBound > upperBound) { + (lowerBound + upperBound) / 2f + } else { + wanted.coerceIn(lowerBound, upperBound) + } + effective[index] = position + previous = position + } + return effective + } + + private fun scaledMinimums(totalPx: Float, minimumPaneSizesPx: FloatArray): FloatArray { + val minimums = FloatArray(paneCount) { index -> + minimumPaneSizesPx.getOrNull(index)?.coerceAtLeast(minPanePx) ?: minPanePx + } + val sum = minimums.sum() + if (sum <= totalPx || sum <= 0f) return minimums + val scale = totalPx.coerceAtLeast(0f) / sum + return FloatArray(paneCount) { index -> minimums[index] * scale } + } + companion object { + private val EMPTY_MINIMUMS = FloatArray(0) + /** Enough to still see a table header and grab the divider again. */ const val DEFAULT_MIN_PANE_PX = 24f diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/StationColumns.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationColumns.kt index 511f7558..59c3262c 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/StationColumns.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationColumns.kt @@ -45,7 +45,14 @@ object StationColumns { formatBand: (ChatMember, kst4contest.model.Band, Boolean) -> String, activeBands: EnumSet = EnumSet.allOf(kst4contest.model.Band::class.java), ): List> = buildList { - add(DataColumn(id = "callsign", title = { kst4contest.view.i18n.CurrentStrings.get().columnCallsign }, value = { it.callSign.orEmpty() })) + add(DataColumn( + id = "callsign", + title = { kst4contest.view.i18n.CurrentStrings.get().columnCallsign }, + value = { it.callSign.orEmpty() }, + comparator = compareBy(String.CASE_INSENSITIVE_ORDER) { member: ChatMember -> + member.callSign?.trim().orEmpty() + }, + )) add(DataColumn(id = "name", title = { kst4contest.view.i18n.CurrentStrings.get().columnName }, value = { it.name.orEmpty() })) add(DataColumn(id = "qra", title = "QRA", value = { it.qra.orEmpty() }, weight = 0.8f)) add(DataColumn( diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/StationContextMenu.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationContextMenu.kt new file mode 100644 index 00000000..f179fb9e --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationContextMenu.kt @@ -0,0 +1,314 @@ +package kst4contest.view.compose + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.LocalMinimumInteractiveComponentSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties +import kst4contest.model.Band +import kst4contest.model.ChatMember +import kst4contest.view.i18n.LocalStrings + +internal const val STATION_CONTEXT_MENU_TAG = "station-context-menu" +internal const val STATION_CONTEXT_TARGET_CHOOSER_TAG = "station-context-target-chooser" +internal const val STATION_SKED_POPUP_TAG = "station-sked-popup" + +private enum class StationContextPage { + ROOT, + SNIPPETS, + NOT_QRV, + WORKED, + REACHABILITY, +} + +/** Shared station action popup used by the user list and the map. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun StationContextOverlays( + menuState: StationContextMenuState, + mainWindowState: MainWindowState, + onShowMap: (() -> Unit)? = null, +) { + val strings = LocalStrings.current + var page by remember { mutableStateOf(StationContextPage.ROOT) } + var skedDraft by remember { mutableStateOf(null) } + var skedPosition by remember { mutableStateOf(IntOffset.Zero) } + + LaunchedEffect(menuState.candidates, menuState.target) { + page = StationContextPage.ROOT + } + + if (menuState.isOpen) { + Popup( + popupPositionProvider = WindowPointPopupPositionProvider(menuState.position), + onDismissRequest = menuState::close, + properties = PopupProperties(focusable = true), + ) { + Surface( + modifier = Modifier.testTag(STATION_CONTEXT_MENU_TAG), + shape = MenuDefaults.shape, + color = MenuDefaults.containerColor, + tonalElevation = MenuDefaults.TonalElevation, + shadowElevation = MenuDefaults.ShadowElevation, + ) { + CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { + Column( + modifier = Modifier + .widthIn(min = 190.dp, max = 360.dp) + .verticalScroll(rememberScrollState()) + .padding(vertical = 4.dp), + ) { + val target = menuState.target + if (target == null) { + ContextHeading( + strings.contextChooseStation, + Modifier.testTag(STATION_CONTEXT_TARGET_CHOOSER_TAG), + ) + menuState.candidates.forEach { candidate -> + ContextItem(stationContextTargetLabel(candidate)) { + menuState.chooseTarget(candidate) + } + } + } else { + when (page) { + StationContextPage.ROOT -> { + ContextSubmenu(strings.contextMessageSnippets) { + page = StationContextPage.SNIPPETS + } + ContextItem(strings.contextCreateSked) { + skedPosition = menuState.position + skedDraft = mainWindowState.selectedStation.createSkedDraft(target) + menuState.close() + } + ContextSeparator() + ContextSubmenu(strings.contextTagNotQrv) { + page = StationContextPage.NOT_QRV + } + ContextSubmenu(strings.contextMarkWorked) { + page = StationContextPage.WORKED + } + ContextSeparator() + ContextSubmenu(strings.contextCalculateReachability) { + page = StationContextPage.REACHABILITY + } + } + + StationContextPage.SNIPPETS -> { + ContextBack { page = StationContextPage.ROOT } + mainWindowState.snippets.entries.forEach { snippet -> + ContextItem(snippet) { + mainWindowState.chatInput.appendSnippet(snippet) + menuState.close() + } + } + } + + StationContextPage.NOT_QRV -> { + ContextBack { page = StationContextPage.ROOT } + NotQrvBand.entries + .filter { it.band in mainWindowState.activeBands } + .forEach { band -> + val tagged = mainWindowState.selectedStation + .isTaggedNotQrv(target, band) + ContextCheckItem(band.label, tagged) { + mainWindowState.selectedStation + .tagNotQrv(target, band, !tagged) + } + } + } + + StationContextPage.WORKED -> { + ContextBack { page = StationContextPage.ROOT } + mainWindowState.activeBands.sortedBy(Band::ordinal).forEach { band -> + val worked = target.isWorkedOn(band) + ContextCheckItem(band.displayLabel, worked, enabled = !worked) { + if (mainWindowState.markStationWorked?.invoke(target, band) == true) { + menuState.close() + } + } + } + } + + StationContextPage.REACHABILITY -> { + ContextBack { page = StationContextPage.ROOT } + mainWindowState.activeBands.sortedBy(Band::ordinal).forEach { band -> + ContextItem(band.displayLabel) { + if (mainWindowState.mapReachability.calculate(target, band)) { + onShowMap?.invoke() + menuState.close() + } + } + } + } + } + } + } + } + } + } + } + + skedDraft?.let { draft -> + Popup( + popupPositionProvider = WindowPointPopupPositionProvider(skedPosition), + onDismissRequest = { skedDraft = null }, + properties = PopupProperties(focusable = true), + ) { + Surface( + modifier = Modifier + .widthIn(min = 420.dp, max = 560.dp) + .testTag(STATION_SKED_POPUP_TAG), + shape = MaterialTheme.shapes.small, + color = MaterialTheme.colorScheme.surface, + tonalElevation = 4.dp, + shadowElevation = 8.dp, + ) { + Column( + modifier = Modifier.padding(8.dp), + ) { + Text( + strings.contextSkedTitle(draft.target.callSign.orEmpty()), + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(bottom = 4.dp), + ) + SkedEditorControls( + activeBands = mainWindowState.activeBands, + minutes = draft.minutes, + onMinutesChanged = { draft.minutes = it }, + band = draft.band, + onBandChanged = { draft.band = it }, + mode = draft.mode, + onModeChanged = { draft.mode = it }, + remindPm = draft.remindPm, + onRemindPmChanged = { draft.remindPm = it }, + reminderOffsets = draft.reminderOffsets, + onReminderOffsetsChanged = { draft.reminderOffsets = it }, + canCreate = draft.band != null, + onCreate = { + if (draft.submit()) skedDraft = null + }, + additionalActions = { + Form.button(strings.contextCancel) { skedDraft = null } + }, + ) + } + } + } + } +} + +@Composable +private fun ContextHeading(text: String, modifier: Modifier = Modifier) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + modifier = modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp), + ) +} + +@Composable +private fun ContextBack(onClick: () -> Unit) { + val strings = LocalStrings.current + ContextItem("‹ ${strings.contextBack}", onClick = onClick) + ContextSeparator() +} + +@Composable +private fun ContextSubmenu(text: String, onClick: () -> Unit) { + ContextItem("$text ›", onClick = onClick) +} + +@Composable +private fun ContextCheckItem( + text: String, + checked: Boolean, + enabled: Boolean = true, + onClick: () -> Unit, +) { + ContextItem((if (checked) "✓ " else " ") + text, enabled, onClick) +} + +@Composable +private fun ContextItem(text: String, enabled: Boolean = true, onClick: () -> Unit) { + DropdownMenuItem( + text = { Text(text, style = compactControlTextStyle(), maxLines = 1) }, + enabled = enabled, + onClick = onClick, + contentPadding = PaddingValues(horizontal = 8.dp), + modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT), + ) +} + +@Composable +private fun ContextSeparator() { + HorizontalDivider( + thickness = Density.HAIRLINE, + color = MaterialTheme.colorScheme.outlineVariant, + ) +} + +internal fun stationContextTargetLabel(member: ChatMember): String { + val category = member.chatCategory + val categoryName = category?.getChatCategoryName(category.categoryNumber) + ?.takeIf { it.isNotBlank() } + ?: category?.categoryNumber?.toString().orEmpty() + return if (categoryName.isBlank()) member.callSign.orEmpty() + else "${member.callSign.orEmpty()} | $categoryName" +} + +internal fun ChatMember.isWorkedOn(band: Band): Boolean = when (band) { + Band.B_50 -> isWorked50 + Band.B_70 -> isWorked70 + Band.B_144 -> isWorked144 + Band.B_432 -> isWorked432 + Band.B_1296 -> isWorked1240 + Band.B_2320 -> isWorked2300 + Band.B_3400 -> isWorked3400 + Band.B_5760 -> isWorked5600 + Band.B_10G -> isWorked10G + Band.B_24G -> isWorked24G +} + +/** Window-relative popup position clamped to the visible window on every DPI scale. */ +internal data class WindowPointPopupPositionProvider( + val point: IntOffset, +) : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + ): IntOffset = IntOffset( + x = point.x.coerceIn(0, maxOf(0, windowSize.width - popupContentSize.width)), + y = point.y.coerceIn(0, maxOf(0, windowSize.height - popupContentSize.height)), + ) +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/StationContextMenuState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationContextMenuState.kt new file mode 100644 index 00000000..40ddbd93 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationContextMenuState.kt @@ -0,0 +1,59 @@ +package kst4contest.view.compose + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.unit.IntOffset +import kst4contest.model.ChatMember + +/** Current target and window-relative anchor of a station context menu. */ +class StationContextMenuState( + private val selectStation: (ChatMember, StationSelectionSource) -> Unit, +) { + var position: IntOffset by mutableStateOf(IntOffset.Zero) + private set + + var candidates: List by mutableStateOf(emptyList()) + private set + + var target: ChatMember? by mutableStateOf(null) + private set + + var source: StationSelectionSource by mutableStateOf(StationSelectionSource.PROGRAMMATIC) + private set + + val isOpen: Boolean + get() = candidates.isNotEmpty() + + fun open( + members: List, + source: StationSelectionSource, + position: IntOffset, + ) { + val unique = members + .filter { StationIdentity.of(it) != null } + .distinctBy(StationIdentity::of) + if (unique.isEmpty()) { + close() + return + } + + this.position = position + this.source = source + candidates = unique + target = unique.singleOrNull() + target?.let { selectStation(it, source) } + } + + fun chooseTarget(member: ChatMember) { + val identity = StationIdentity.of(member) ?: return + val resolved = candidates.firstOrNull { StationIdentity.of(it) == identity } ?: return + target = resolved + selectStation(resolved, source) + } + + fun close() { + candidates = emptyList() + target = null + } +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterBar.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterBar.kt index b0644d62..5cc30f96 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterBar.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterBar.kt @@ -3,49 +3,53 @@ package kst4contest.view.compose import kst4contest.view.i18n.LocalStrings 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.platform.testTag import androidx.compose.ui.unit.dp +internal const val FILTER_DISTANCE_GROUP_TAG = "filter-distance-group" +internal const val FILTER_DIRECTION_GROUP_TAG = "filter-direction-group" +internal const val FILTER_STATION_GROUP_TAG = "filter-station-group" +internal const val FILTER_WORKED_GROUP_TAG = "filter-worked-group" +internal const val FILTER_PROPAGATION_GROUP_TAG = "filter-propagation-group" + /** * The station filter bar above the station table. * - * Laid out to match the JavaFX original: three tight rows of controls separated - * by thin vertical rules between logical groups. + * Compact surfaces keep related filters together while the outer flow rows still wrap + * against the station panel's actual width. */ @OptIn(ExperimentalLayoutApi::class) @Composable fun StationFilterBar( filters: StationFilterState, activeBands: Set, + reachability: ReachabilityState, onChanged: () -> Unit, + onQtfDirectionChanged: (Double) -> Unit = {}, + onReset: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { val strings = LocalStrings.current Column( - modifier = modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 1.dp), + modifier = modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.18f)) + .border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant) + .padding(horizontal = 2.dp, vertical = 1.dp), verticalArrangement = Arrangement.spacedBy(1.dp), ) { /* @@ -61,121 +65,6 @@ fun StationFilterBar( horizontalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(1.dp), itemVerticalAlignment = Alignment.CenterVertically, - ) { - // Reset filters - FilterButton(strings.filterReset, on = false, isAccent = true) { - filters.reset() - onChanged() - } - - // QRB maximum + minimum (#21): together they make a range, so they stay one group - FilterGroup { - GroupSeparator() - FlowRow( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalArrangement = Arrangement.spacedBy(1.dp), - itemVerticalAlignment = Alignment.CenterVertically, - ) { - FilterGroup { - CompactCheckbox( - checked = filters.maxQrbEnabled, - onCheckedChange = { - filters.maxQrbEnabled = it - onChanged() - }, - ) - Text(strings.filterShowOnlyQrb, style = MaterialTheme.typography.bodySmall) - CompactTextField( - value = if (filters.maxQrbKm.rem(1) == 0.0) filters.maxQrbKm.toInt().toString() else filters.maxQrbKm.toString(), - onValueChange = { - val parsed = parseQrbKm(it) - if (parsed != null) { - filters.maxQrbKm = parsed - if (filters.maxQrbEnabled) onChanged() - } else if (it.isEmpty()) { - filters.maxQrbKm = 0.0 - if (filters.maxQrbEnabled) onChanged() - } - }, - modifier = Modifier.width(50.dp) - ) - } - FilterGroup { - CompactCheckbox( - checked = filters.minQrbEnabled, - onCheckedChange = { - filters.minQrbEnabled = it - onChanged() - }, - ) - Text(strings.filterShowOnlyQrbAbove, style = MaterialTheme.typography.bodySmall) - CompactTextField( - value = if (filters.minQrbKm.rem(1) == 0.0) filters.minQrbKm.toInt().toString() else filters.minQrbKm.toString(), - onValueChange = { - val parsed = parseQrbKm(it) - if (parsed != null) { - filters.minQrbKm = parsed - if (filters.minQrbEnabled) onChanged() - } else if (it.isEmpty()) { - filters.minQrbKm = 0.0 - if (filters.minQrbEnabled) onChanged() - } - }, - modifier = Modifier.width(50.dp) - ) - } - } - } - - // QTF checkbox + field - FilterGroup { - GroupSeparator() - CompactCheckbox( - checked = filters.qtfEnabled, - onCheckedChange = { - filters.qtfEnabled = it - onChanged() - }, - ) - Text(strings.filterShowOnlyQtf, style = MaterialTheme.typography.bodySmall) - CompactTextField( - value = if (filters.qtfDegrees.rem(1) == 0.0) filters.qtfDegrees.toInt().toString() else filters.qtfDegrees.toString(), - onValueChange = { - val parsed = it.toDoubleOrNull() - if (parsed != null) { - filters.qtfDegrees = parsed - filters.bearing = null - if (filters.qtfEnabled) onChanged() - } else if (it.isEmpty()) { - filters.qtfDegrees = 0.0 - filters.bearing = null - if (filters.qtfEnabled) onChanged() - } - }, - modifier = Modifier.width(50.dp) - ) - Text("± BW/2", style = MaterialTheme.typography.bodySmall) - } - - // Bearings - FlowRow( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalArrangement = Arrangement.spacedBy(1.dp), - ) { - 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, @@ -187,100 +76,225 @@ fun StationFilterBar( modifier = Modifier.width(90.dp), ) - VerticalSeparator() - - Text(strings.filterHideWorked, style = MaterialTheme.typography.bodySmall) - FilterButton("wkdany", on = filters.hideWorkedAny) { - filters.hideWorkedAny = !filters.hideWorkedAny - onChanged() + // Reset filters + FilterButton(strings.filterReset, on = false, isAccent = true) { + if (onReset != null) { + onReset() + } else { + filters.reset() + onChanged() + } } - WorkedBandFilter.entries.forEach { band -> - if (band.modelBand in activeBands) { - FilterButton(band.label, on = filters.isHideWorkedOn(band)) { - filters.setHideWorkedOn(band, !filters.isHideWorkedOn(band)) - onChanged() + + // Distance and bearing-width controls form one functional surface. + FilterGroupSurface(FILTER_DISTANCE_GROUP_TAG) { + FilterGroup { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(1.dp), + itemVerticalAlignment = Alignment.CenterVertically, + ) { + FilterGroup { + CompactCheckbox( + checked = filters.maxQrbEnabled, + onCheckedChange = { + filters.maxQrbEnabled = it + onChanged() + }, + ) + Text(strings.filterShowOnlyQrb, style = compactControlTextStyle()) + CompactTextField( + value = if (filters.maxQrbKm.rem(1) == 0.0) { + filters.maxQrbKm.toInt().toString() + } else { + filters.maxQrbKm.toString() + }, + onValueChange = { + val parsed = parseQrbKm(it) + if (parsed != null) { + filters.maxQrbKm = parsed + if (filters.maxQrbEnabled) onChanged() + } else if (it.isEmpty()) { + filters.maxQrbKm = 0.0 + if (filters.maxQrbEnabled) onChanged() + } + }, + modifier = Modifier.width(50.dp), + ) + } + FilterGroup { + CompactCheckbox( + checked = filters.minQrbEnabled, + onCheckedChange = { + filters.minQrbEnabled = it + onChanged() + }, + ) + Text(strings.filterShowOnlyQrbAbove, style = compactControlTextStyle()) + CompactTextField( + value = if (filters.minQrbKm.rem(1) == 0.0) { + filters.minQrbKm.toInt().toString() + } else { + filters.minQrbKm.toString() + }, + onValueChange = { + val parsed = parseQrbKm(it) + if (parsed != null) { + filters.minQrbKm = parsed + if (filters.minQrbEnabled) onChanged() + } else if (it.isEmpty()) { + filters.minQrbKm = 0.0 + if (filters.minQrbEnabled) onChanged() + } + }, + modifier = Modifier.width(50.dp), + ) + } } } } - VerticalSeparator() + FilterGroupSurface(FILTER_DIRECTION_GROUP_TAG) { + FilterGroup { + CompactCheckbox( + checked = filters.qtfEnabled, + onCheckedChange = { + filters.qtfEnabled = it + onChanged() + }, + ) + Text(strings.filterShowOnlyQtf, style = compactControlTextStyle()) + CompactTextField( + value = if (filters.qtfDegrees.rem(1) == 0.0) { + filters.qtfDegrees.toInt().toString() + } else { + filters.qtfDegrees.toString() + }, + onValueChange = { + val parsed = parseQtfDegrees(it) + if (parsed != null) { + filters.qtfDegrees = parsed + filters.bearing = null + onQtfDirectionChanged(parsed) + if (filters.qtfEnabled) onChanged() + } else if (it.isEmpty()) { + filters.qtfDegrees = 0.0 + filters.bearing = null + if (filters.qtfEnabled) onChanged() + } + }, + modifier = Modifier.width(50.dp), + ) + Text("± BW/2", style = compactControlTextStyle()) + } - FilterButton(strings.filterInactiveStations, on = filters.hideInactive) { - filters.hideInactive = !filters.hideInactive - onChanged() + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(1.dp), + ) { + Bearing.entries.forEach { bearing -> + FilterButton( + bearing.name, + on = filters.bearing == bearing, + modifier = Modifier.width(Density.DIRECTION_BUTTON_WIDTH), + horizontalPadding = Density.DIRECTION_BUTTON_HORIZONTAL_PADDING, + ) { + filters.toggleBearing(bearing) + if (filters.bearing == bearing) { + onQtfDirectionChanged(bearing.degrees) + } + onChanged() + } + } + } } - FilterButton(strings.filterOnlyNewGrids, on = filters.onlyNewGrids) { - filters.onlyNewGrids = !filters.onlyNewGrids - onChanged() - } - } - // ROW 3: Grid color | Tropo | New bands | AS next 5m - Row( - modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - FilterButton(strings.filterGridColour, on = filters.gridColouring) { - filters.gridColouring = !filters.gridColouring + FilterGroupSurface(FILTER_STATION_GROUP_TAG) { + Text(strings.filterHide, style = compactControlTextStyle()) + 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() + } + } + } + FilterButton(strings.filterInactiveStations, on = filters.hideInactive) { + filters.hideInactive = !filters.hideInactive + onChanged() + } } - FilterButton(strings.filterTropo, on = filters.tropoReachable) { - filters.tropoReachable = !filters.tropoReachable - onChanged() + + FilterGroupSurface(FILTER_WORKED_GROUP_TAG) { + Text(strings.filterShow, style = compactControlTextStyle()) + FilterButton(strings.filterOnlyNewGrids, on = filters.onlyNewGrids) { + filters.onlyNewGrids = !filters.onlyNewGrids + onChanged() + } + FilterButton(strings.filterGridColour, on = filters.gridColouring) { + filters.gridColouring = !filters.gridColouring + } + FilterButton(strings.filterTropo, on = filters.tropoReachable) { + filters.tropoReachable = !filters.tropoReachable + onChanged() + } + FilterButton(strings.filterNewBands, on = filters.newBands) { + filters.newBands = !filters.newBands + onChanged() + } + FilterButton("AS next 5m", on = filters.airScoutNext5Min) { + filters.airScoutNext5Min = !filters.airScoutNext5Min + onChanged() + } } - FilterButton(strings.filterNewBands, on = filters.newBands) { - filters.newBands = !filters.newBands - onChanged() - } - FilterButton("AS next 5m", on = filters.airScoutNext5Min) { - filters.airScoutNext5Min = !filters.airScoutNext5Min - onChanged() + + FilterGroupSurface(FILTER_PROPAGATION_GROUP_TAG) { + FilterGroup { + Text(strings.filterReachability, style = compactControlTextStyle()) + Form.inlineChoice( + label = "", + items = reachability.options, + selected = reachability.selectedOption, + describe = ReachabilityBandOption::label, + onSelect = { reachability.selectBand(it.band) }, + ) + Form.button( + text = strings.filterCalcSelected, + enabled = reachability.canCalculate, + onClick = { reachability.calculateSelected() }, + ) + } } } } } /** - * A compact toggle button that does NOT use Material 3 Button (which enforces 40dp). - * Instead it is a plain clickable Box with border and background, matching the tiny - * JavaFX filter buttons. + * A compact toggle using the same interaction states as every other desktop button. */ @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) - } +private fun FilterButton( + label: String, + on: Boolean, + isAccent: Boolean = false, + enabled: Boolean = true, + modifier: Modifier = Modifier, + horizontalPadding: androidx.compose.ui.unit.Dp = 6.dp, + onClick: () -> Unit, +) { + Form.button( + text = label, + enabled = enabled, + accented = isAccent, + selected = on, + modifier = modifier, + horizontalPadding = horizontalPadding, + onClick = onClick, + ) } /** Controls that belong together and move to the next line of a wrapping row as one. */ @@ -294,62 +308,20 @@ private fun FilterGroup(content: @Composable () -> Unit) { } } -/** - * The group divider for a wrapping row. A fixed height, because [VerticalSeparator] fills - * the row's intrinsic height, which a line of a flow row does not offer. - */ +/** A compact surface that keeps related filters visually and spatially together. */ +@OptIn(ExperimentalLayoutApi::class) @Composable -private fun GroupSeparator() { - Box( +private fun FilterGroupSurface(tag: String, content: @Composable () -> Unit) { + FlowRow( modifier = Modifier - .width(Density.HAIRLINE) - .height(18.dp) - .background(MaterialTheme.colorScheme.outline), - ) -} - -/** Thin vertical separator matching JavaFX's group dividers. */ -@Composable -private fun VerticalSeparator() { - Box( - modifier = Modifier - .width(Density.HAIRLINE) - .fillMaxHeight() - .background(MaterialTheme.colorScheme.outline), - ) -} - - -@Composable -private fun CompactTextField( - value: String, - onValueChange: (String) -> Unit, - placeholder: String = "", - modifier: Modifier = Modifier, -) { - BasicTextField( - value = value, - onValueChange = onValueChange, - singleLine = true, - textStyle = MaterialTheme.typography.bodySmall.copy(color = MaterialTheme.colorScheme.onSurface), - decorationBox = { innerTextField -> - Box( - modifier = modifier - .background(MaterialTheme.colorScheme.surfaceVariant, Density.BUTTON_SHAPE) - .border(1.dp, MaterialTheme.colorScheme.outline, Density.BUTTON_SHAPE) - .padding(horizontal = 4.dp, vertical = 2.dp), - contentAlignment = Alignment.CenterStart, - ) { - if (value.isEmpty() && placeholder.isNotEmpty()) { - Text( - placeholder, - style = MaterialTheme.typography.bodySmall.copy( - color = MaterialTheme.colorScheme.onSurfaceVariant, - ), - ) - } - innerTextField() - } - }, - ) + .testTag(tag) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.42f)) + .border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant, Density.BUTTON_SHAPE) + .padding(2.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + itemVerticalAlignment = Alignment.CenterVertically, + ) { + content() + } } diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterState.kt index b1f767cf..385e3e5f 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterState.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterState.kt @@ -45,6 +45,10 @@ enum class StationFilter { internal fun parseQrbKm(text: String): Double? = text.toDoubleOrNull()?.takeIf { it.isFinite() && it >= 0.0 } +/** A deliberate antenna/filter heading in the convention used by the QTF display. */ +internal fun parseQtfDegrees(text: String): Double? = + text.toDoubleOrNull()?.takeIf { it.isFinite() && it in 0.0..360.0 } + /** The bands the "hide worked" row offers, under the labels the bar shows. */ 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), diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/StationSelectionState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationSelectionState.kt new file mode 100644 index 00000000..3b59b5f0 --- /dev/null +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationSelectionState.kt @@ -0,0 +1,135 @@ +package kst4contest.view.compose + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import kst4contest.model.ChatMember +import kst4contest.model.ChatMessage +import java.util.Locale + +/** Full ON4KST station identity used across tables, messages and the map. */ +data class StationIdentity( + val callSign: String, + val categoryNumber: Int?, +) { + companion object { + @JvmStatic + fun of(member: ChatMember?): StationIdentity? = + of(member?.callSign, member?.chatCategory?.categoryNumber) + + @JvmStatic + fun of(callSignValue: String?, categoryNumber: Int?): StationIdentity? { + val callSign = callSignValue?.trim()?.uppercase(Locale.ROOT).orEmpty() + if (callSign.isBlank()) return null + + return StationIdentity(callSign, categoryNumber) + } + } +} + +/** Resolves the station navigated to by a directed-message row click. */ +object DirectedMessageStationTarget { + + @JvmStatic + fun forMessage(message: ChatMessage?, ownCallSign: String?): StationIdentity? { + val senderCallSign = message?.sender?.callSign ?: return null + val ownCall = ownCallSign?.trim()?.uppercase(Locale.ROOT).orEmpty() + val senderCall = senderCallSign.trim().uppercase(Locale.ROOT) + val targetCall = if (senderCall == ownCall) { + message.receiver?.callSign ?: receiverFromPayload(message.messageText) + } else { + senderCallSign + } + + return StationIdentity.of(targetCall, message.chatCategory?.categoryNumber) + } + + private fun receiverFromPayload(messageText: String?): String? { + if (messageText == null || !messageText.startsWith('(')) return null + val closing = messageText.indexOf(')') + return if (closing > 1) messageText.substring(1, closing).trim() else null + } +} + +/** Where an explicit station selection came from. */ +enum class StationSelectionSource { + STATION_TABLE, + STATION_CONTEXT_MENU, + DIRECTED_MESSAGE, + PUBLIC_MESSAGE, + QSO_OF_THE_OTHER, + MAP, + MAP_CONTEXT_MENU, + PRIORITY, + NEW_JOINED, + TIMELINE, + RESET_VIEW, + PROGRAMMATIC, +} + +/** + * Canonical station selection shared by the station table and detail views. + * + * Data refreshes reconcile the selected instance without moving the table. Explicit + * user navigation increments [scrollRequestRevision], even when the identity is the + * same as before. + */ +class StationSelectionState : TableSelectionModel { + + private var current: ChatMember? by mutableStateOf(null) + + override val selected: ChatMember? + get() = current + + override var selectionRevision: Long by mutableStateOf(0L) + private set + + override var scrollRequestRevision: Long by mutableStateOf(0L) + private set + + var lastSource: StationSelectionSource by mutableStateOf(StationSelectionSource.PROGRAMMATIC) + private set + + val identity: StationIdentity? + get() = StationIdentity.of(current) + + override fun select(row: ChatMember?) { + select(row, StationSelectionSource.STATION_TABLE) + } + + fun select(row: ChatMember?, source: StationSelectionSource) { + current = row + lastSource = source + selectionRevision++ + if (row != null && source.requestsStationTableScroll()) { + scrollRequestRevision++ + } + } + + override fun reconcile(row: ChatMember?) { + current = row + selectionRevision++ + } + + override fun requestScroll() { + if (current != null) { + scrollRequestRevision++ + } + } +} + +private fun StationSelectionSource.requestsStationTableScroll(): Boolean = when (this) { + StationSelectionSource.STATION_TABLE, + StationSelectionSource.STATION_CONTEXT_MENU, + StationSelectionSource.RESET_VIEW, + StationSelectionSource.PROGRAMMATIC -> false + + StationSelectionSource.DIRECTED_MESSAGE, + StationSelectionSource.PUBLIC_MESSAGE, + StationSelectionSource.QSO_OF_THE_OTHER, + StationSelectionSource.MAP, + StationSelectionSource.MAP_CONTEXT_MENU, + StationSelectionSource.PRIORITY, + StationSelectionSource.NEW_JOINED, + StationSelectionSource.TIMELINE -> true +} diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/TimelineState.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/TimelineState.kt index 1746fd66..8d96bb72 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/TimelineState.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/TimelineState.kt @@ -49,6 +49,23 @@ data class TimelineCandidate( val tooltipText: String?, ) +/** The four candidate groups produced for every minute bucket, in display order. */ +enum class TimelineLane(val index: Int) { + IN_BEAM_1(0), + IN_BEAM_2(1), + PRIORITY_1(2), + PRIORITY_2(3); + + companion object { + fun forIndex(index: Int): TimelineLane = entries[index.coerceIn(0, entries.lastIndex)] + } +} + +data class TimelineTick( + val minutes: Int, + val x: Float, +) + /** * Where things sit on the strip. * @@ -67,27 +84,27 @@ object TimelineGeometry { * 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 + const val HEIGHT: Float = 104f /** The time axis, with the candidate lanes above it and the skeds below. */ - const val AXIS_Y: Float = 44f + const val AXIS_Y: Float = 70f + const val TICK_LABEL_Y: Float = 72f /** Candidates keep further from the edges than skeds do, because their labels are wider. */ 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 + const val LANE_HEIGHT: Float = 16f /** - * 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. + * How many candidate lanes the producer defines: two in-beam choices followed by + * two global priority choices. */ - const val LANE_COUNT: Int = 2 + const val LANE_COUNT: Int = 4 /** Skeds get their own lane below the axis, which is what frees the labels. */ - const val SKED_Y: Float = 52f + const val SKED_Y: Float = 86f const val DEFAULT_BEAM_WIDTH_DEG: Double = 50.0 @@ -124,6 +141,23 @@ object TimelineGeometry { fun laneY(laneIndex: Int): Float = LANE_BASE_Y + LANE_HEIGHT * laneIndex.coerceIn(0, LANE_COUNT - 1) + + /** + * Relative minute ticks for the current preview window. Five-minute ticks are used + * whenever their labels fit; compact layouts increase the interval instead of + * drawing overlapping text. + */ + fun minuteTicks(axisWidth: Float, minimumLabelSpacing: Float): List { + if (axisWidth <= 0f) return emptyList() + + val previewMinutes = (PREVIEW_MILLIS / 60_000L).toInt() + val interval = listOf(5, 10, 15, previewMinutes).first { minutes -> + axisWidth * minutes / previewMinutes >= minimumLabelSpacing + } + return (0..previewMinutes step interval).map { minutes -> + TimelineTick(minutes, axisWidth * minutes / previewMinutes) + } + } } /** diff --git a/app-desktop/src/main/kotlin/kst4contest/view/compose/TimelineView.kt b/app-desktop/src/main/kotlin/kst4contest/view/compose/TimelineView.kt index b2a66095..b4ee34be 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/TimelineView.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/TimelineView.kt @@ -11,6 +11,7 @@ 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.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -34,6 +35,7 @@ import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import kst4contest.model.ContestSked +import kst4contest.view.i18n.LocalStrings import kotlin.math.roundToInt /** The strip is read like a radar screen, so it stays dark in both designs. */ @@ -47,6 +49,8 @@ private val MARKER_SIZE = 12.dp private val LABEL_GAP = 4.dp private val CHIP_SHAPE = RoundedCornerShape(6.dp) private val CHIP_BACKGROUND = Color(0xA6000000) +private val LANE_LABEL_WIDTH = 68.dp +private val AXIS_LABEL_PADDING = 6.dp /** * The strip above the send field: what is coming in the next half hour. @@ -82,6 +86,7 @@ fun TimelineView( val density = LocalDensity.current val measurer = rememberTextMeasurer() + val strings = LocalStrings.current BoxWithConstraints( modifier @@ -89,17 +94,60 @@ fun TimelineView( .background(STRIP_BACKGROUND), ) { val widthPx = with(density) { maxWidth.toPx() } + val labelGutterPx = with(density) { LANE_LABEL_WIDTH.toPx() }.coerceAtMost(widthPx) + val axisWidthPx = (widthPx - labelGutterPx).coerceAtLeast(0f) val axisY = with(density) { TimelineGeometry.AXIS_Y.dp.toPx() } val candidateMargin = with(density) { TimelineGeometry.CANDIDATE_MARGIN.dp.toPx() } val skedMargin = with(density) { TimelineGeometry.SKED_MARGIN.dp.toPx() } + val tickLabels = TimelineGeometry.minuteTicks( + axisWidth = axisWidthPx, + minimumLabelSpacing = measurer.measure("+30 ${strings.timelineMinuteSuffix}", AXIS_TEXT_STYLE) + .size.width.toFloat() + with(density) { AXIS_LABEL_PADDING.toPx() }, + ).map { tick -> + tick to if (tick.minutes == 0) { + "0 ${strings.timelineMinuteSuffix}" + } else { + "+${tick.minutes} ${strings.timelineMinuteSuffix}" + } + } Canvas(Modifier.fillMaxSize()) { - drawLine(AXIS_COLOR, Offset(0f, axisY), Offset(size.width, axisY)) + drawLine(AXIS_COLOR, Offset(labelGutterPx, axisY), Offset(size.width, axisY)) + tickLabels.forEach { (tick, _) -> + val x = labelGutterPx + tick.x + drawLine(AXIS_COLOR, Offset(x, axisY - 2.dp.toPx()), Offset(x, axisY + 3.dp.toPx())) + } + } + + val laneLabels = listOf( + strings.timelineInBeam1, + strings.timelineInBeam2, + strings.timelinePriority1, + strings.timelinePriority2, + ) + laneLabels.forEachIndexed { index, label -> + TimelineAxisLabel(label, TimelineGeometry.laneY(index).dp) + } + TimelineAxisLabel(strings.timelineSkeds, TimelineGeometry.SKED_Y.dp) + + tickLabels.forEach { (tick, label) -> + val labelWidthPx = measurer.measure(label, AXIS_TEXT_STYLE).size.width.toFloat() + val x = (labelGutterPx + tick.x - labelWidthPx / 2f) + .coerceIn(labelGutterPx, (widthPx - labelWidthPx).coerceAtLeast(labelGutterPx)) + Text( + text = label, + modifier = Modifier.offset { + IntOffset(x.roundToInt(), with(density) { TimelineGeometry.TICK_LABEL_Y.dp.toPx() }.roundToInt()) + }, + style = AXIS_TEXT_STYLE, + maxLines = 1, + ) } // The candidate lanes, above the axis. state.candidates.forEach { candidate -> - val x = TimelineGeometry.xFor(candidate.timeUntilMs, widthPx, candidateMargin) + val x = TimelineGeometry.xFor(candidate.timeUntilMs, axisWidthPx, candidateMargin) + ?.plus(labelGutterPx) ?: return@forEach TimelineMarker( @@ -120,7 +168,8 @@ fun TimelineView( // 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) + val x = TimelineGeometry.xFor(sked.skedTimeEpoch - now, axisWidthPx, skedMargin) + ?.plus(labelGutterPx) ?: return@forEach TimelineMarker( @@ -157,6 +206,23 @@ private val CHIP_TEXT_STYLE = TextStyle( fontWeight = FontWeight.Bold, ) +private val AXIS_TEXT_STYLE = TextStyle( + color = Color(0xFFB8B8B8), + fontSize = 8.sp, +) + +@Composable +private fun TimelineAxisLabel(text: String, top: Dp) { + Text( + text = text, + modifier = Modifier + .offset(x = 4.dp, y = top) + .width(LANE_LABEL_WIDTH - 8.dp), + style = AXIS_TEXT_STYLE, + maxLines = 1, + ) +} + /** * One marker: the symbol on the time axis and its callsign beside it. * 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 index 9e81f338..448f8be3 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/map/ComposeStationMap.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/ComposeStationMap.kt @@ -12,6 +12,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.geometry.Offset @@ -24,7 +25,11 @@ import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.toComposeImageBitmap import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.isSecondaryPressed +import androidx.compose.ui.input.pointer.onPointerEvent import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.TextMeasurer import androidx.compose.ui.text.TextStyle @@ -50,6 +55,7 @@ import kotlin.math.ceil import kotlin.math.floor import kotlin.math.log2 import kotlin.math.sign +import kotlin.math.roundToInt /** * Where the map is looking. Compose state, so a pan or a zoom reaches the screen. @@ -94,6 +100,7 @@ class MapState( * any zoom with a fraction, the antenna beam did not start where the map said home was. * Neither failure is possible here, because nothing is scaled. */ +@OptIn(ExperimentalComposeUiApi::class) @Composable fun ComposeStationMap( markers: List, @@ -106,6 +113,8 @@ fun ComposeStationMap( antennaBeamWidthDeg: Double, maxQrbKm: Double, onMarkerClicked: ((MapCallsignRawSnapshot) -> Unit)?, + onMarkerContextRequested: ((List, IntOffset) -> Unit)? = null, + workedGrossFields: Set = emptySet(), darkMode: Boolean = true, modifier: Modifier = Modifier, ) { @@ -122,6 +131,7 @@ fun ComposeStationMap( * wrongly grouped markers whenever the window is resized. */ var canvasSize by remember { mutableStateOf(Size.Zero) } + var canvasCoordinates by remember { mutableStateOf(null) } /* Bumped when a tile arrives, which is what asks for the frame that shows it. */ var tileRevision by remember { mutableStateOf(0) } @@ -196,6 +206,39 @@ fun ComposeStationMap( modifier = modifier .clipToBounds() .onSizeChanged { canvasSize = Size(it.width.toFloat(), it.height.toFloat()) } + .onGloballyPositioned { canvasCoordinates = it } + .onPointerEvent(PointerEventType.Press) { event -> + if (event.buttons.isSecondaryPressed) { + val local = event.changes.firstOrNull()?.position ?: return@onPointerEvent + val viewport = mapState.viewportFor(canvasSize.width, canvasSize.height) + val result = StationMapClusterer.cluster(markers, viewport, groupingEnabled) + val cluster = result.clusters().firstOrNull { candidate -> + within( + local, + viewport.screenX(candidate.centerLon).toFloat(), + viewport.screenY(candidate.centerLat).toFloat(), + clusterRadiusPx(candidate.size()), + ) + } + val individual = result.individual().firstOrNull { marker -> + within( + local, + viewport.screenX(marker.longitudeDeg()).toFloat(), + viewport.screenY(marker.latitudeDeg()).toFloat(), + MARKER_HIT_RADIUS_PX, + ) + } + val targets = cluster?.getMarkers() ?: individual?.let(::listOf) + val window = canvasCoordinates?.localToWindow(local) + if (!targets.isNullOrEmpty() && window != null) { + onMarkerContextRequested?.invoke( + targets, + IntOffset(window.x.roundToInt(), window.y.roundToInt()), + ) + event.changes.forEach { it.consume() } + } + } + } .pointerInput(Unit) { detectTransformGestures { centroid, pan, zoomChange, _ -> var viewport = mapState.viewportFor(size.width.toFloat(), size.height.toFloat()) @@ -293,6 +336,7 @@ fun ComposeStationMap( drawTiles(viewport, imageCache, darkMode) drawMaidenheadGrid(viewport, textMeasurer, darkMode) + drawWorkedGrossFields(viewport, workedGrossFields) drawBeamAndPath(viewport, home, antennaAzimuthDeg, antennaBeamWidthDeg, maxQrbKm, markers, selectedMarkerCallsign, darkMode) drawStations(viewport, clusterResult, selectedMarkerCallsign, textMeasurer, darkMode) drawHover(viewport, hoverPosition, clusterResult, textMeasurer) @@ -709,10 +753,7 @@ private fun DrawScope.drawHover( val text = when { clusterUnderPointer != null -> MapMarkerClusterTooltip.build(clusterUnderPointer) - markerUnderPointer != null -> markerUnderPointer.locator6().let { locator -> - if (locator.isBlank()) markerUnderPointer.displayCallSign() - else "${markerUnderPointer.displayCallSign()} ($locator)" - } + markerUnderPointer != null -> markerUnderPointer.tooltipText() else -> return } @@ -758,6 +799,46 @@ private fun DrawScope.drawHover( ) } +private fun DrawScope.drawWorkedGrossFields( + viewport: MapViewport, + workedGrossFields: Set, +) { + visibleWorkedGrossFieldCells(viewport, workedGrossFields).forEach { cell -> + val left = viewport.screenX(cell.westLon()).toFloat() + val right = viewport.screenX(cell.eastLon()).toFloat() + val top = viewport.screenY(cell.northLat()).toFloat() + val bottom = viewport.screenY(cell.southLat()).toFloat() + val topLeft = Offset(minOf(left, right), minOf(top, bottom)) + val cellSize = Size(kotlin.math.abs(right - left), kotlin.math.abs(bottom - top)) + drawRect( + color = WORKED_GRID_FILL, + topLeft = topLeft, + size = cellSize, + ) + drawRect( + color = WORKED_GRID_BORDER, + topLeft = topLeft, + size = cellSize, + style = Stroke(width = WORKED_GRID_BORDER_WIDTH_PX), + ) + } +} + +/** Worked four-character cells visible in the current viewport, independent of markers. */ +internal fun visibleWorkedGrossFieldCells( + viewport: MapViewport, + workedGrossFields: Set, +): List { + if (workedGrossFields.isEmpty()) return emptyList() + return MaidenheadGridUtils.buildVisibleCells( + viewport.southLat(), + viewport.westLon(), + viewport.northLat(), + viewport.eastLon(), + MaidenheadGridUtils.GridPrecision.SQUARE_4, + ).filter { it.locatorLabel() in workedGrossFields } +} + /* ----------------------------------------------------------- helpers */ private val MapMarkerCluster.centerLon: Double get() = getCenterLon() @@ -864,6 +945,9 @@ private const val GRID_LINE_WIDTH_PX = 1.4f private val GRID_LABEL_COLOR = Color(0xFF63067A) private val GRID_LABEL_BACKGROUND_LIGHT = Color.White.copy(alpha = 0.18f) private val GRID_LABEL_BACKGROUND_DARK = Color(0xFF22262B).copy(alpha = 0.20f) +private val WORKED_GRID_FILL = Color.Black.copy(alpha = 0.30f) +private val WORKED_GRID_BORDER = Color.Black +private const val WORKED_GRID_BORDER_WIDTH_PX = 3f private const val GRID_LABEL_PADDING_PX = 3f private val DOT_FILL = Color(0xFF1D1D1D) 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 index e4f90743..8199b4de 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/map/StationMapWindow.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/map/StationMapWindow.kt @@ -20,13 +20,20 @@ import kst4contest.view.ApplicationRuntimeLauncher import kst4contest.view.compose.PaletteStore import kst4contest.view.compose.ComposeWindowHost import kst4contest.view.compose.MainWindowState +import kst4contest.view.compose.StationIdentity +import kst4contest.view.compose.StationContextMenuState +import kst4contest.view.compose.StationContextOverlays +import kst4contest.view.compose.StationSelectionSource import kst4contest.view.map.MapCallsignRawSnapshotBuilder import kst4contest.view.map.PathAnalysisResult import kst4contest.view.map.StationMapStatusText import kst4contest.view.map.TileFetcher import java.util.EnumSet -@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) +@OptIn( + androidx.compose.foundation.ExperimentalFoundationApi::class, + androidx.compose.foundation.layout.ExperimentalLayoutApi::class, +) object StationMapWindow { private val host = ComposeWindowHost("Map") @@ -60,14 +67,16 @@ object StationMapWindow { widthDp = widthDp, heightDp = heightDp, onResized = onResized, + alwaysOnTop = mainWindowState.prefs.isGUIstationMapAlwaysOnTop, ) { _ -> val window = this.window /* * Never larger than the smallest screen: AWT enforces a minimum size even past * the edge, and some operators work on 640x480. */ - androidx.compose.runtime.LaunchedEffect(window) { + androidx.compose.runtime.LaunchedEffect(window, mainWindowState.prefs.isGUIstationMapAlwaysOnTop) { window?.minimumSize = WindowPlacementOnScreen.minimumSizeOnScreens(900, 600) + window?.isAlwaysOnTop = mainWindowState.prefs.isGUIstationMapAlwaysOnTop } val tileFetcher = remember { TileFetcher() } @@ -80,7 +89,11 @@ object StationMapWindow { onDispose { tileFetcher.close() } } - Content(mainWindowState = mainWindowState, tileFetcher = tileFetcher) + Content( + mainWindowState = mainWindowState, + tileFetcher = tileFetcher, + onAlwaysOnTopChanged = { enabled -> window?.isAlwaysOnTop = enabled }, + ) } // host.show content } @@ -108,6 +121,7 @@ object StationMapWindow { }, mapState: MapState = remember { MapState() }, pathAnalysis: () -> MapPathAnalysis? = ::runtimePathAnalysis, + onAlwaysOnTopChanged: (Boolean) -> Unit = { }, ) { val strings = LocalStrings.current @@ -128,6 +142,10 @@ object StationMapWindow { var groupingEnabled by remember { mutableStateOf(prefs.isGUIstationMapClusteringEnabled) } var pathAnalysisVisible by remember { mutableStateOf(prefs.isGUIstationMapPathAnalysisVisible) } + var alwaysOnTop by remember { mutableStateOf(prefs.isGUIstationMapAlwaysOnTop) } + val stationContextMenu = remember(mainWindowState) { + StationContextMenuState(mainWindowState::selectStation) + } val selectedMember = mainWindowState.selectedStation.selected @@ -159,7 +177,7 @@ object StationMapWindow { } builder.buildSnapshots( - mainWindowState.stations.visibleRows, + mainWindowState.stations.filteredRows, selectedMember, bandSet ) @@ -167,6 +185,7 @@ object StationMapWindow { } var pathAnalysisResult by remember { mutableStateOf(null) } + var automaticAnalysisRevision by remember { mutableStateOf(0L) } // Read through updated state so a caller passing a new lambda is never served a stale one. val currentPathAnalysis by rememberUpdatedState(pathAnalysis) @@ -179,6 +198,8 @@ object StationMapWindow { markers.any { it.callSignRaw() == selectedMember.callSignRaw } LaunchedEffect(selectedMember, selectedHasMarker) { + val automaticRequest = ++automaticAnalysisRevision + val explicitRevisionAtStart = mainWindowState.mapReachability.requestRevision if (selectedMember == null) { pathAnalysisResult = null return@LaunchedEffect @@ -219,11 +240,31 @@ object StationMapWindow { ) analysis.request(selectedMember, selectedSnapshot) { result -> - pathAnalysisResult = result + if (automaticAnalysisRevision == automaticRequest && + mainWindowState.mapReachability.requestRevision == explicitRevisionAtStart + ) { + pathAnalysisResult = result + } } } } + val explicitReachabilityResult = mainWindowState.mapReachability.result + LaunchedEffect(mainWindowState.mapReachability.requestRevision, explicitReachabilityResult) { + if (explicitReachabilityResult != null) { + automaticAnalysisRevision++ + pathAnalysisResult = explicitReachabilityResult + pathAnalysisVisible = true + } + } + + val workedGrossFields = if (mainWindowState.stationFilter.gridColouring) { + mainWindowState.stations.contentRevision + mainWindowState.workedGrossFieldsProvider() + } else { + emptySet() + } + androidx.compose.material3.Surface( color = androidx.compose.material3.MaterialTheme.colorScheme.background, contentColor = androidx.compose.material3.MaterialTheme.colorScheme.onBackground, @@ -237,10 +278,11 @@ object StationMapWindow { * 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( + FlowRow( modifier = Modifier.fillMaxWidth().padding(8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp) + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + itemVerticalAlignment = Alignment.CenterVertically, ) { val statusText = StationMapStatusText.build( markers.size, @@ -274,8 +316,7 @@ object StationMapWindow { } kst4contest.view.compose.Form.button(strings.mapResetView) { - mainWindowState.stationSelector?.invoke(null) - ?: mainWindowState.selectedStation.select(null) + mainWindowState.selectStation(null, StationSelectionSource.RESET_VIEW) val home = runCatching { kst4contest.locatorUtils.Location(ownLocator6) }.getOrNull() @@ -284,12 +325,38 @@ object StationMapWindow { /* 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 + kst4contest.view.compose.Form.button(strings.filterReset) { + mainWindowState.resetStationFilters() + } + + androidx.compose.foundation.TooltipArea( + tooltip = { kst4contest.view.compose.TooltipCard(strings.mapGroupNearbyTooltip) }, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + kst4contest.view.compose.CompactCheckbox( + checked = groupingEnabled, + modifier = Modifier.testTag(GROUPING_TAG), + ) { + groupingEnabled = it + prefs.isGUIstationMapClusteringEnabled = it + mainWindowState.requestLayoutSave() + } + Text(strings.mapGroupNearby, modifier = Modifier.padding(start = 4.dp)) } - Text(strings.mapGroupNearby, modifier = Modifier.padding(start = 4.dp)) + } + + Row(verticalAlignment = Alignment.CenterVertically) { + kst4contest.view.compose.CompactCheckbox( + checked = alwaysOnTop, + onCheckedChange = { enabled -> + alwaysOnTop = enabled + prefs.isGUIstationMapAlwaysOnTop = enabled + onAlwaysOnTopChanged(enabled) + mainWindowState.requestLayoutSave() + }, + modifier = Modifier.testTag(ALWAYS_ON_TOP_TAG), + ) + Text(strings.mapAlwaysOnTop, modifier = Modifier.padding(start = 4.dp)) } if (!pathAnalysisVisible) { @@ -305,6 +372,7 @@ object StationMapWindow { ) { pathAnalysisVisible = !pathAnalysisVisible prefs.isGUIstationMapPathAnalysisVisible = pathAnalysisVisible + mainWindowState.requestLayoutSave() } } @@ -333,6 +401,7 @@ object StationMapWindow { antennaBeamWidthDeg = antennaBeamWidthDeg, maxQrbKm = maxQrbKm, darkMode = isDarkTheme, + workedGrossFields = workedGrossFields, onMarkerClicked = { snapshot -> val variants = chatMembers() .filter { it.callSignRaw == snapshot.callSignRaw() } @@ -340,19 +409,28 @@ object StationMapWindow { * A marker stands for a raw callsign, not a category. Keep the * category already selected when it is one of the variants. */ - val member = variants.firstOrNull { it === mainWindowState.selectedStation.selected } - ?: variants.firstOrNull() + val selectedIdentity = StationIdentity.of(mainWindowState.selectedStation.selected) + val member = variants.firstOrNull { + StationIdentity.of(it) == selectedIdentity + } ?: variants.firstOrNull { + it.chatCategory?.categoryNumber == + prefs.loginChatCategoryMain?.categoryNumber + } ?: variants.singleOrNull() if (member != null) { - val selector = mainWindowState.stationSelector - if (selector != null) { - selector(member) - } else { - mainWindowState.selectedStation.select(member) - } + mainWindowState.selectStation(member, StationSelectionSource.MAP) // Mirror old JavaFX handleMapCallsignSelection logic: prefill /cq mainWindowState.chatInput.prepareCq(member.callSign ?: "", forceOverwrite = true) } }, + onMarkerContextRequested = { snapshots, position -> + val callsigns = snapshots.mapTo(linkedSetOf()) { it.callSignRaw() } + val candidates = chatMembers().filter { it.callSignRaw in callsigns } + stationContextMenu.open( + candidates, + StationSelectionSource.MAP_CONTEXT_MENU, + position, + ) + }, modifier = Modifier.fillMaxSize().testTag(MAP_CANVAS_TAG) ) @@ -416,10 +494,13 @@ object StationMapWindow { } // main layout column } // window column } // Surface + StationContextOverlays(stationContextMenu, mainWindowState) } // Content /** Marks the map canvas so a test can click a marker on it. */ internal const val MAP_CANVAS_TAG = "station-map-canvas" + internal const val GROUPING_TAG = "station-map-grouping" + internal const val ALWAYS_ON_TOP_TAG = "station-map-always-on-top" @JvmStatic fun applyDarkMode(darkMode: Boolean) { 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 index 0526d2f6..d2175ba5 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTab.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTab.kt @@ -1,12 +1,15 @@ package kst4contest.view.compose.tabs import kst4contest.view.i18n.LocalStrings +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.TooltipArea import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import kst4contest.view.compose.Form +import kst4contest.view.compose.TooltipCard /** * The Station tab: own station, server, active bands and path-analysis defaults. @@ -16,6 +19,7 @@ import kst4contest.view.compose.Form * * @param onRefused shows why a committed entry was refused (the own ground height) */ +@OptIn(ExperimentalFoundationApi::class) @Composable fun StationTab(state: StationTabState, onRefused: (String) -> Unit = {}) { val strings = LocalStrings.current @@ -86,10 +90,32 @@ fun StationTab(state: StationTabState, onRefused: (String) -> Unit = {}) { Form.section(strings.stationDefaultsSection) { Form.decimal(strings.stationMaxQrb, state.stn_maxQRBDefault) { state.stn_maxQRBDefault = it } - Form.decimal(strings.stationQtf, state.stn_qtfDefault) { state.stn_qtfDefault = it } + Form.decimal( + label = strings.stationQtf, + value = state.stn_qtfDefault, + isValid = { it.isFinite() && it in 0.0..360.0 }, + ) { state.stn_qtfDefault = it } Form.decimal(strings.stationBeamWidth, state.stn_antennaBeamWidthDeg) { state.stn_antennaBeamWidthDeg = it } } + Form.panelSection(strings.stationManualAntennaHeadingSection) { + val headingOption: @Composable () -> Unit = { + Form.check( + strings.stationUseQtfFilterDirectionAsAntennaHeading, + state.stn_useQtfFilterDirectionAsAntennaHeading, + enabled = !state.stn_pstRotatorEnabled, + ) { state.stn_useQtfFilterDirectionAsAntennaHeading = it } + } + if (state.stn_pstRotatorEnabled) { + TooltipArea( + tooltip = { TooltipCard(strings.stationManualAntennaHeadingRotatorHint) }, + content = headingOption, + ) + } else { + headingOption() + } + } + Form.section(strings.stationRotatorSection) { Form.check(strings.stationRotatorEnabled, state.stn_pstRotatorEnabled) { state.stn_pstRotatorEnabled = it } Form.text(strings.stationRotatorHost, state.stn_pstRotatorHost) { state.stn_pstRotatorHost = 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 index a86a2355..053ff051 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTabState.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/tabs/StationTabState.kt @@ -165,7 +165,13 @@ class StationTabState( return null } - var stn_pstRotatorEnabled: Boolean by draft.setting({ prefs.isStn_pstRotatorEnabled() }) { prefs.setStn_pstRotatorEnabled(it) } + var stn_pstRotatorEnabled: Boolean by draft.setting( + { prefs.isStn_pstRotatorEnabled() }, + ) { prefs.setStn_pstRotatorEnabled(it) } + + var stn_useQtfFilterDirectionAsAntennaHeading: Boolean by draft.setting( + { prefs.isStn_useQtfFilterDirectionAsAntennaHeading() }, + ) { prefs.setStn_useQtfFilterDirectionAsAntennaHeading(it) } var stn_pstRotatorHost: String by draft.setting({ prefs.getStn_pstRotatorHost() }) { prefs.setStn_pstRotatorHost(it) } diff --git a/app-desktop/src/test/java/kst4contest/view/RosterListBindingTest.java b/app-desktop/src/test/java/kst4contest/view/RosterListBindingTest.java index 164afa41..d67abfc9 100644 --- a/app-desktop/src/test/java/kst4contest/view/RosterListBindingTest.java +++ b/app-desktop/src/test/java/kst4contest/view/RosterListBindingTest.java @@ -1,12 +1,19 @@ package kst4contest.view; +import kst4contest.model.ChatCategory; +import kst4contest.model.ChatMember; +import kst4contest.model.ChatMessage; import kst4contest.observe.DirectUiDispatcher; import kst4contest.observe.SimpleRoster; +import kst4contest.view.compose.SelectedMessageFilter; +import kst4contest.view.compose.SelectedStationMessagePredicate; import org.junit.jupiter.api.Test; import java.util.Comparator; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Predicate; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -136,6 +143,39 @@ class RosterListBindingTest { "a newly set filter must be visible without waiting for the next roster change"); } + @Test + void selectedStationMessagesFollowNewMessagesAndSelectionChanges() { + ChatMember first = member("DL1FIRST", 2); + ChatMember second = member("DL2SECOND", 2); + SimpleRoster messages = new SimpleRoster<>(); + AtomicReference> filter = new AtomicReference<>( + SelectedStationMessagePredicate.forSelection( + SelectedMessageFilter.PUBLIC, first, "DL0OWN")); + + RosterListBinding binding = RosterListBinding.derived( + new DirectUiDispatcher(), + () -> messages.snapshot().stream().filter(filter.get()).toList(), + messages); + + ChatMessage unrelated = publicMessage(second); + messages.add(unrelated); + assertTrue(binding.list().isEmpty()); + + ChatMessage matching = publicMessage(first); + messages.add(matching); + assertEquals(List.of(matching), List.copyOf(binding.list())); + + filter.set(SelectedStationMessagePredicate.forSelection( + SelectedMessageFilter.PUBLIC, second, "DL0OWN")); + binding.refresh(); + assertEquals(List.of(unrelated), List.copyOf(binding.list()), + "the former station's messages must disappear immediately"); + + ChatMessage next = publicMessage(second); + messages.add(next); + assertEquals(List.of(unrelated, next), List.copyOf(binding.list())); + } + @Test void aDisposedDerivedBindingDetachesFromEverySource() { SimpleRoster members = new SimpleRoster<>(); @@ -150,4 +190,20 @@ class RosterListBindingTest { assertTrue(binding.list().isEmpty()); } + + private static ChatMember member(String callSign, int category) { + ChatMember member = new ChatMember(); + member.setCallSign(callSign); + member.setChatCategory(new ChatCategory(category)); + return member; + } + + private static ChatMessage publicMessage(ChatMember sender) { + ChatMessage message = new ChatMessage(); + message.setSender(sender); + message.setReceiver(member("ALL", sender.getChatCategory().getCategoryNumber())); + message.setChatCategory(sender.getChatCategory()); + message.setMessageText("test"); + return message; + } } diff --git a/app-desktop/src/test/java/kst4contest/view/SelectedStationMessageFlowTest.java b/app-desktop/src/test/java/kst4contest/view/SelectedStationMessageFlowTest.java new file mode 100644 index 00000000..986a6e01 --- /dev/null +++ b/app-desktop/src/test/java/kst4contest/view/SelectedStationMessageFlowTest.java @@ -0,0 +1,180 @@ +package kst4contest.view; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Predicate; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import kst4contest.ApplicationConstants; +import kst4contest.controller.ChatController; +import kst4contest.controller.MessageBusManagementThread; +import kst4contest.controller.ScoreService; +import kst4contest.controller.StationMetricsService; +import kst4contest.controller.ThreadStatusCallback; +import kst4contest.model.AirPlaneReflectionInfo; +import kst4contest.model.ChatCategory; +import kst4contest.model.ChatMember; +import kst4contest.model.ChatMessage; +import kst4contest.model.ChatPreferences; +import kst4contest.observe.DirectUiDispatcher; +import kst4contest.observe.SimpleRoster; +import kst4contest.observe.SimpleValue; +import kst4contest.view.compose.DataTableState; +import kst4contest.view.compose.RowKeys; +import kst4contest.view.compose.SelectedMessageFilter; +import kst4contest.view.compose.SelectedStationMessagePredicate; +import kst4contest.view.feed.SelectedStationMessagesFeed; + +/** Exercises protocol parsing, selected-station filtering and the displayed table together. */ +class SelectedStationMessageFlowTest { + + private static final String OWN_CALL = "DL0OWN"; + private static final String SELECTED_CALL = "DM5M"; + + private final LinkedBlockingQueue receiveQueue = new LinkedBlockingQueue<>(); + private final SimpleRoster messages = new SimpleRoster<>(); + private final AtomicReference> filter = new AtomicReference<>(); + + private ChatController controller; + private ChatPreferences preferences; + private ChatMember selected; + private RosterListBinding binding; + private DataTableState displayed; + + @BeforeEach + void setUp() { + preferences = mock(ChatPreferences.class); + when(preferences.getStn_loginCallSign()).thenReturn(OWN_CALL); + when(preferences.getStn_loginLocatorMainCat()).thenReturn("JO50LL"); + when(preferences.getMYQRGFirstCat()).thenReturn(new SimpleValue<>("144.300")); + when(preferences.getMYQRGSecondCat()).thenReturn(new SimpleValue<>("1296.200")); + + controller = mock(ChatController.class); + when(controller.getChatPreferences()).thenReturn(preferences); + when(controller.getChatCategoryMain()).thenReturn(new ChatCategory(ChatCategory.VUHF)); + when(controller.getChatCategorySecondChat()).thenReturn(new ChatCategory(ChatCategory.MICROWAVE)); + when(controller.getStationMetricsService()).thenReturn(mock(StationMetricsService.class)); + when(controller.getScoreService()).thenReturn(mock(ScoreService.class)); + when(controller.findActiveChatMember(any(ChatMember.class))) + .thenAnswer(invocation -> activeMember(invocation.getArgument(0))); + doAnswer(invocation -> { + messages.add(invocation.getArgument(0)); + return null; + }).when(controller).publishChatMessage(any(ChatMessage.class)); + + selected = member(SELECTED_CALL, ChatCategory.VUHF); + filter.set(SelectedStationMessagePredicate.forSelection( + SelectedMessageFilter.PM_TO_ME, selected, OWN_CALL)); + + displayed = new DataTableState<>(List.of(), RowKeys.INSTANCE.byReference(), "selected-flow-test"); + SelectedStationMessagesFeed feed = + new SelectedStationMessagesFeed(displayed, new DirectUiDispatcher()); + binding = RosterListBinding.derived( + new DirectUiDispatcher(), + () -> messages.snapshot().stream().filter(filter.get()).toList(), + messages); + binding.onChanged(feed::push); + feed.push(binding.list()); + } + + @Test + @Timeout(10) + void realisticFramesReachTheDisplayedRowsUnderAllFourModes() throws InterruptedException { + runThrough( + chatFrame(2, SELECTED_CALL, "private", OWN_CALL), + chatFrame(2, SELECTED_CALL, "(" + OWN_CALL + ") public reply", "0"), + chatFrame(2, SELECTED_CALL, "general public message", "0"), + chatFrame(2, SELECTED_CALL, "private to another station", "DL2OTHER"), + chatFrame(2, "DL2OTHER", "other station", "0"), + chatFrame(3, SELECTED_CALL, "same call, other category", "0")); + + assertEquals(2, displayed.getRows().size(), + "pm to me must contain the genuine PM and the explicitly addressed public line"); + + selectMode(SelectedMessageFilter.PUBLIC); + assertEquals(2, displayed.getRows().size(), + "the two public lines overlap with the addressed-public PM view"); + + selectMode(SelectedMessageFilter.PM_TO_OTHER); + assertEquals(1, displayed.getRows().size(), + "pm to other keeps private traffic sent by the selected station to another station"); + + selectMode(SelectedMessageFilter.NOTHING); + assertEquals(4, displayed.getRows().size(), + "nothing removes only the type/receiver restriction"); + + selectStation(member("DL2OTHER", ChatCategory.VUHF)); + assertEquals(2, displayed.getRows().size(), + "changing the selected station must replace the displayed station scope immediately"); + selectStation(member(SELECTED_CALL, ChatCategory.VUHF)); + + runThrough(chatFrame(2, SELECTED_CALL, "new while the panel is open", OWN_CALL)); + assertEquals(5, displayed.getRows().size(), + "a new matching line must reach the already open table"); + } + + private void selectMode(SelectedMessageFilter mode) { + filter.set(SelectedStationMessagePredicate.forSelection(mode, selected, OWN_CALL)); + binding.refresh(); + } + + private void selectStation(ChatMember station) { + selected = station; + selectMode(SelectedMessageFilter.NOTHING); + } + + private void runThrough(ChatMessage... frames) throws InterruptedException { + MessageBusManagementThread thread = new MessageBusManagementThread( + controller, + mock(ThreadStatusCallback.class), + 11L, + receiveQueue, + ignored -> true); + for (ChatMessage frame : frames) { + receiveQueue.add(frame); + } + receiveQueue.add(poisonPill()); + thread.start(); + thread.join(); + } + + private static ChatMessage chatFrame(int category, String sender, String text, String receiver) { + return wireMessage("CH|" + category + "|1700000000|" + sender + + "|Operator|0|" + text + "|" + receiver + "|"); + } + + private static ChatMessage poisonPill() { + ChatMessage message = wireMessage(ApplicationConstants.DISCONNECT_RDR_POISONPILL); + message.setMessageSenderName(ApplicationConstants.DISCONNECT_RDR_POISONPILL); + return message; + } + + private static ChatMessage wireMessage(String text) { + ChatMessage message = new ChatMessage(); + message.setMessageText(text); + return message; + } + + private static ChatMember activeMember(ChatMember lookup) { + return member(lookup.getCallSign(), lookup.getChatCategory().getCategoryNumber()); + } + + private static ChatMember member(String callSign, int category) { + ChatMember member = new ChatMember(); + member.setCallSign(callSign); + member.setChatCategory(new ChatCategory(category)); + member.setQra("JO31AA"); + member.setAirPlaneReflectInfo(new AirPlaneReflectionInfo()); + return member; + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/ChatInputRowSo2rScreenTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/ChatInputRowSo2rScreenTest.kt index 1a6dddcc..de482760 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/ChatInputRowSo2rScreenTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/ChatInputRowSo2rScreenTest.kt @@ -3,19 +3,23 @@ package kst4contest.view.compose import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.dp import kst4contest.model.ChatCategory import kst4contest.model.ChatMember import kst4contest.model.ChatMessage import kst4contest.model.ChatPreferences +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.io.TempDir import java.nio.file.Path +import kotlin.math.abs /** The send line with SO2R: the display-only QTF2 field appears only while SO2R is on. */ @OptIn(ExperimentalTestApi::class) @@ -66,4 +70,53 @@ class ChatInputRowSo2rScreenTest { onNodeWithText("270.0").assertExists() onNodeWithText("270.0").assertIsNotEnabled() } + + @Test + fun `send buttons message and station fields share height and preserve realistic values`() = runComposeUiTest { + val prefs = prefs().apply { + getMYQRGFirstCat().set("1296.123.000") + getMYQRGSecondCat().set("144.310.000") + getActualQTF().set(360.0) + } + val state = chatInputStateFor(prefs).apply { + value = TextFieldValue("/cq OK1IN test test test aasdasd ggggg asd asd gggg") + } + setContent { + Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { + ChatInputRow( + state = state, + prefs = prefs, + ownQrgMain = ObservedValue(prefs.getMYQRGFirstCat()), + ownQrgSecond = ObservedValue(prefs.getMYQRGSecondCat()), + antennaQtf = ObservedValue(prefs.getActualQTF()), + antennaQtf2 = ObservedValue(prefs.getActualQTF2()), + so2rEnabled = false, + ) + } + } + + val values = listOf("1296.123.000", "144.310.000", "360.0") + val heights = listOf("TX", "clear") + .map { onNodeWithText(it).fetchSemanticsNode().boundsInRoot.height } + + values.map { onNodeWithText(it).fetchSemanticsNode().boundsInRoot.height } + assertTrue( + heights.max() - heights.min() <= 1f, + "send-row control heights differ: $heights", + ) + + val minimumQrgWidth = with(density) { 96.dp.toPx() } + values.take(2).forEach { value -> + val width = onNodeWithText(value).fetchSemanticsNode().boundsInRoot.width + assertTrue(width >= minimumQrgWidth - 1f, "$value has only $width px") + } + val qtfWidth = onNodeWithText("360.0").fetchSemanticsNode().boundsInRoot.width + assertTrue( + abs(qtfWidth - with(density) { 56.dp.toPx() }) <= 1f, + "360.0 field is $qtfWidth px wide", + ) + assertTrue( + abs(onNodeWithText("TX").fetchSemanticsNode().boundsInRoot.height - + with(density) { 26.dp.toPx() }) <= 1f, + ) + } } diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/CompactControlsScreenTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/CompactControlsScreenTest.kt new file mode 100644 index 00000000..b6e656de --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/CompactControlsScreenTest.kt @@ -0,0 +1,149 @@ +package kst4contest.view.compose + +import androidx.compose.foundation.layout.Column +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.dp +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import kotlin.math.abs + +/** Shared fields and choices use the same compact desktop height. */ +@OptIn(ExperimentalTestApi::class) +class CompactControlsScreenTest { + + @Test + fun fieldsChoicesAndButtonsShareTheDesktopControlHeight() = runComposeUiTest { + setContent { + Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { + Column { + CompactTextField(value = "144.310.000", onValueChange = {}) + Form.inlineChoice( + label = "Reachability", + items = listOf("Auto"), + selected = "Auto", + describe = { it }, + onSelect = {}, + ) + Form.button(text = "TX", onClick = {}) + Form.button(text = "Filter", onClick = {}) + } + } + } + + val heights = listOf("144.310.000", "Auto", "TX", "Filter") + .map { onNodeWithText(it).fetchSemanticsNode().boundsInRoot.height } + val targetHeight = with(density) { 26.dp.toPx() } + + assertTrue( + heights.all { abs(it - targetHeight) <= 1f }, + "controls are $heights px tall instead of $targetHeight px", + ) + assertTrue( + heights.max() - heights.min() <= 1f, + "control heights differ by more than one pixel: $heights", + ) + } + + @Test + fun accentAndToggleButtonsShareEnabledAndDisabledClickBehaviour() = runComposeUiTest { + var accentClicks = 0 + var disabledClicks = 0 + setContent { + Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) { + Column { + Form.button( + text = "Accent", + accented = true, + onClick = { accentClicks++ }, + ) + Form.button( + text = "Selected", + selected = true, + onClick = {}, + ) + Form.button( + text = "Disabled accent", + enabled = false, + accented = true, + onClick = { disabledClicks++ }, + ) + } + } + } + + onNodeWithText("Accent").performClick() + onNodeWithText("Disabled accent").performClick() + + assertEquals(1, accentClicks) + assertEquals(0, disabledClicks) + assertTrue(onNodeWithText("Selected").fetchSemanticsNode().boundsInRoot.height < 40f) + } + + @Test + fun controlsStayAlignedAt125PercentDensityInEveningDesign() = runComposeUiTest { + setContent { + CompositionLocalProvider( + LocalDensity provides androidx.compose.ui.unit.Density(1.25f), + ) { + Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) { + Column { + CompactTextField(value = "10368.000", onValueChange = {}) + Form.inlineChoice( + label = "Reachability", + items = listOf("Auto"), + selected = "Auto", + describe = { it }, + onSelect = {}, + ) + Form.button(text = "TX", onClick = {}) + } + } + } + } + + val heights = listOf("10368.000", "Auto", "TX") + .map { onNodeWithText(it).fetchSemanticsNode().boundsInRoot.height } + val expected = 26f * 1.25f + assertTrue( + heights.all { abs(it - expected) <= 1f }, + "125% controls are $heights px tall instead of about $expected px", + ) + assertTrue( + heights.max() - heights.min() <= 1f, + "125% control heights differ by more than one pixel: $heights", + ) + } + + @Test + fun controlsGrowTogetherInsteadOfClippingALargerConfiguredFont() = runComposeUiTest { + setContent { + Kst4ContestTheme(darkMode = false, baseFontSizeSp = 24f) { + Column { + CompactTextField(value = "1296.123.000", onValueChange = {}) + Form.inlineChoice( + label = "Reachability", + items = listOf("Auto"), + selected = "Auto", + describe = { it }, + onSelect = {}, + ) + Form.button(text = "TX", onClick = {}) + } + } + } + + val heights = listOf("1296.123.000", "Auto", "TX") + .map { onNodeWithText(it).fetchSemanticsNode().boundsInRoot.height } + assertTrue(heights.min() > with(density) { 26.dp.toPx() }) + assertTrue( + heights.max() - heights.min() <= 1f, + "large-font controls do not grow together: $heights", + ) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/CompactControlsVisualRenderingTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/CompactControlsVisualRenderingTest.kt new file mode 100644 index 00000000..720fc450 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/CompactControlsVisualRenderingTest.kt @@ -0,0 +1,114 @@ +package kst4contest.view.compose + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import kst4contest.model.Band +import kst4contest.model.ChatCategory +import kst4contest.model.ChatMember +import kst4contest.model.ChatMessage +import kst4contest.model.ChatPreferences +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Files +import java.nio.file.Path +import java.util.EnumSet + +/** Renders the compact control strips for visual inspection in both supported palettes. */ +class CompactControlsVisualRenderingTest(@param:TempDir private val tempDir: Path) { + + @Test + fun rendersDayAndEveningControlsAt100And125Percent() { + val reportDirectory = Path.of("build", "reports", "compact-controls") + Files.createDirectories(reportDirectory) + + listOf(false, true).forEach { darkMode -> + listOf(1f, 1.25f).forEach { scale -> + val bytes = render(darkMode, scale) + val palette = if (darkMode) "evening" else "day" + val scaling = if (scale == 1f) "100" else "125" + Files.write(reportDirectory.resolve("$palette-$scaling.png"), bytes) + assertTrue(bytes.size > 10_000, "$palette at $scaling% did not render") + } + } + } + + private fun render(darkMode: Boolean, scale: Float): ByteArray { + val preferences = ChatPreferences(tempDir.resolve("preferences-$darkMode-$scale.xml")).apply { + getMYQRGFirstCat().set("1296.123.000") + getMYQRGSecondCat().set("144.310.000") + getActualQTF().set(360.0) + } + val chatInputState = ChatInputState(RenderActions(preferences)).apply { + value = TextFieldValue("/cq OK1IN test test test aasdasd ggggg asd asd gggg") + } + val filters = StationFilterState() + + val scene = ImageComposeScene(width = 1366, height = 380, density = Density(scale)) { + Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + Column(Modifier.padding(4.dp)) { + TimelineView( + state = TimelineState(), + onCandidateClicked = {}, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + StationFilterBar( + filters = filters, + activeBands = EnumSet.of(Band.B_144, Band.B_432, Band.B_1296), + reachability = ReachabilityState( + EnumSet.of(Band.B_144, Band.B_432, Band.B_1296), + ), + onChanged = {}, + ) + Spacer(Modifier.height(12.dp)) + ChatInputRow( + state = chatInputState, + prefs = preferences, + ownQrgMain = ObservedValue(preferences.getMYQRGFirstCat()), + ownQrgSecond = ObservedValue(preferences.getMYQRGSecondCat()), + antennaQtf = ObservedValue(preferences.getActualQTF()), + antennaQtf2 = ObservedValue(preferences.getActualQTF2()), + so2rEnabled = false, + ) + } + } + } + } + return try { + scene.render().encodeToData()!!.bytes + } finally { + scene.close() + } + } + + private class RenderActions(private val preferences: ChatPreferences) : ChatInputActions { + override fun panelSelection(): ChatMember? = null + override fun tableSelection(): ChatMember? = null + override fun scoreSelection(): ChatMember? = null + override fun resolveVariables(template: String, member: ChatMember?): String = template + override fun isAddressedToOwnCallsign(text: String): Boolean = false + override fun resolveCategory(text: String, member: ChatMember?): ChatCategory? = null + override fun mainCategory(): ChatCategory? = null + override fun recordOutboundCq(text: String) = Unit + override fun requestRecompute(reason: String) = Unit + override fun queue(message: ChatMessage) = Unit + override fun ownQrgMain(): String = preferences.getMYQRGFirstCat().get().orEmpty() + override fun ownQrgSecond(): String = preferences.getMYQRGSecondCat().get().orEmpty() + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/ConnectionBarScreenTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/ConnectionBarScreenTest.kt new file mode 100644 index 00000000..671487b4 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/ConnectionBarScreenTest.kt @@ -0,0 +1,103 @@ +package kst4contest.view.compose + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import kst4contest.controller.On4KstConnectionState +import kst4contest.model.ChatPreferences +import kst4contest.view.compose.tabs.showTab +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path + +@OptIn(ExperimentalTestApi::class) +class ConnectionBarScreenTest(@param:TempDir private val tempDir: Path) { + + @Test + fun `button availability follows every manager lifecycle state`() { + var connectionState = On4KstConnectionState.DISCONNECTED + val state = ConnectionBarState(preferences()) { connectionState } + + On4KstConnectionState.entries.forEach { lifecycle -> + connectionState = lifecycle + assertEqualsLifecycleRules(lifecycle, state) + } + } + + @Test + fun `async disconnect redraws the open settings connection bar`() = runComposeUiTest { + var connectionState by mutableStateOf(On4KstConnectionState.ONLINE) + val state = ConnectionBarState(preferences()) { connectionState } + + showTab { + ConnectionBar( + state = state, + onConnect = { }, + onSave = { }, + onApplyAndClose = { }, + onDisconnectAndCloseChat = { }, + onDisconnectOnly = { }, + ) + } + + onNodeWithText("Connect", substring = true).assertIsNotEnabled() + onNodeWithText("Disconnect").assertIsEnabled() + + runOnIdle { connectionState = On4KstConnectionState.STOPPING } + onNodeWithText("Connect", substring = true).assertIsNotEnabled() + onNodeWithText("Disconnect").assertIsNotEnabled() + + runOnIdle { connectionState = On4KstConnectionState.DISCONNECTED } + onNodeWithText("Connect", substring = true).assertIsEnabled() + onNodeWithText("Disconnect").assertIsNotEnabled() + } + + @Test + fun `a failed attempt enables one new connection attempt`() = runComposeUiTest { + var connectionState by mutableStateOf(On4KstConnectionState.CONNECTING) + var attempts = 0 + val state = ConnectionBarState(preferences()) { connectionState } + + showTab { + ConnectionBar( + state = state, + onConnect = { + attempts++ + connectionState = On4KstConnectionState.CONNECTING + }, + onSave = { }, + onApplyAndClose = { }, + onDisconnectAndCloseChat = { }, + onDisconnectOnly = { }, + ) + } + + runOnIdle { connectionState = On4KstConnectionState.DISCONNECTED } + onNodeWithText("Connect", substring = true).performClick() + + assertTrue(attempts == 1) + onNodeWithText("Connect", substring = true).assertIsNotEnabled() + } + + private fun assertEqualsLifecycleRules( + lifecycle: On4KstConnectionState, + state: ConnectionBarState, + ) { + assertTrue(state.canConnect == (lifecycle == On4KstConnectionState.DISCONNECTED), lifecycle.name) + assertTrue(state.canDisconnect == lifecycle.isConnectionAttemptActive, lifecycle.name) + if (lifecycle == On4KstConnectionState.STOPPING) { + assertFalse(state.canConnect) + assertFalse(state.canDisconnect) + } + } + + private fun preferences() = ChatPreferences(tempDir.resolve("preferences.xml")) +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/DataTableStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/DataTableStateTest.kt index 885ae960..854f1bc3 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/DataTableStateTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/DataTableStateTest.kt @@ -34,6 +34,97 @@ class DataTableStateTest { rowKey = RowKeys.byValue { s: Station -> s.callSign }, ) + @Test + fun `an initial sort is applied once and keeps later rows in order`() { + val table = DataTableState( + columns = listOf( + DataColumn( + id = "callsign", + title = "Callsign", + value = { station: Station -> station.callSign }, + comparator = compareBy(String.CASE_INSENSITIVE_ORDER) { station: Station -> + station.callSign.trim() + }, + ), + ), + rowKey = RowKeys.byValue { station: Station -> station.callSign }, + initialSortColumnId = "callsign", + ) + + table.replaceRows(listOf(Station("do5amf", 1), Station("DL1ABC", 2))) + assertEquals(listOf("DL1ABC", "do5amf"), table.visibleRows.map { it.callSign }) + + table.replaceRows( + listOf(Station("ZZ9ZZZ", 3), Station("do5amf", 1), Station("AA1AAA", 4), Station("DL1ABC", 2)), + ) + + assertEquals( + listOf("AA1AAA", "DL1ABC", "do5amf", "ZZ9ZZZ"), + table.visibleRows.map { it.callSign }, + ) + } + + @Test + fun `a user sort replaces the initial sort and survives row and filter updates`() { + val table = DataTableState( + columns = listOf( + DataColumn(id = "callsign", title = "Callsign", value = { station: Station -> station.callSign }), + DataColumn( + id = "qrb", + title = "QRB", + value = { station: Station -> station.qrb.toString() }, + comparator = compareBy { station: Station -> station.qrb }, + ), + ), + rowKey = RowKeys.byValue { station: Station -> station.callSign }, + initialSortColumnId = "callsign", + ) + table.replaceRows(listOf(Station("B", 300), Station("A", 100))) + + table.toggleSort("qrb") + table.rowFilter = { it.qrb >= 100 } + table.replaceRows(listOf(Station("C", 200), Station("B", 300), Station("A", 100))) + + assertEquals("qrb", table.sortedColumnId) + assertEquals(listOf("A", "C", "B"), table.visibleRows.map { it.callSign }) + } + + @Test + fun `a selected filtered row can be pinned only in the table view`() { + val table = DataTableState( + columns = listOf(DataColumn(id = "callsign", title = "Callsign", value = { s: Station -> s.callSign })), + rowKey = RowKeys.byValue { station: Station -> station.callSign }, + keepSelectionVisible = true, + ) + val hidden = Station("DL1HIDE", 100) + val shown = Station("DL2SHOW", 500) + table.replaceRows(listOf(hidden, shown)) + table.rowFilter = { it.qrb >= 300 } + + table.select(hidden) + + assertEquals(listOf("DL2SHOW"), table.filteredRows.map { it.callSign }, "the shared map filter changed") + assertEquals( + listOf("DL1HIDE", "DL2SHOW"), + table.visibleRows.map { it.callSign }, + "the selected station was not made reachable in the table", + ) + } + + @Test + fun `the same selected row can request scrolling more than once`() { + val table = stationTable() + val selected = Station("DL1ABC", 100) + table.replaceRows(listOf(selected)) + table.select(selected) + + table.requestScrollToSelection() + val first = table.scrollRequestRevision + table.requestScrollToSelection() + + assertTrue(table.scrollRequestRevision > first) + } + @Test fun `the selection follows the row, not its position`() { val table = stationTable() @@ -388,6 +479,24 @@ class DataTableColumnWidthTest { assertEquals(140.0, table(store).widthOf("callsign")) } + @Test + fun `a stored narrow width wins over the automatic width`() { + val store = RecordingStore() + store.stored["dx-cluster-monitor/callsign"] = 36.0 + val table = table(store) + val column = table.columns.first { it.id == "callsign" } + + assertEquals(36f, effectiveColumnWidthDp(table, column, mapOf("callsign" to 140f))) + } + + @Test + fun `the automatic width is used only without a stored width`() { + val table = table(RecordingStore()) + val column = table.columns.first { it.id == "callsign" } + + assertEquals(140f, effectiveColumnWidthDp(table, column, mapOf("callsign" to 140f))) + } + @Test fun `a column never sized yet has no stored width`() { assertNull(table(RecordingStore()).widthOf("qra"), diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/GuiStartupDesignPersistenceTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/GuiStartupDesignPersistenceTest.kt new file mode 100644 index 00000000..ba50b0b3 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/GuiStartupDesignPersistenceTest.kt @@ -0,0 +1,57 @@ +package kst4contest.view.compose + +import kst4contest.model.ChatPreferences +import kst4contest.view.compose.tabs.GuiOptionsTabState +import kst4contest.view.i18n.LanguageStore +import kst4contest.view.i18n.SYSTEM_LANGUAGE +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path +import java.util.Locale + +class GuiStartupDesignPersistenceTest(@param:TempDir private val tempDir: Path) { + + @Test + fun `root profile keeps evening startup design across save layout autosave and reload`() { + assertStartupDesignSurvives(tempDir.resolve("preferences.xml")) + } + + @Test + fun `named profile keeps evening startup design across save layout autosave and reload`() { + assertStartupDesignSurvives(tempDir.resolve("profiles/CONTEST/preferences.xml")) + } + + private fun assertStartupDesignSurvives(preferencesFile: Path) { + val preferences = ChatPreferences(preferencesFile) + val draft = SettingsDraft() + val notices = SettingsNotices() + val state = GuiOptionsTabState(preferences, draft, languageStore()) + + state.gUI_darkModeActiveByDefault = true + applyAndSave(draft, notices) { + preferences.writePreferencesToXmlFile() + .takeIf { it } + ?.let { preferences.storeAndRestorePreferencesFileName } + } + + assertTrue(preferences.isGUI_darkModeActiveByDefault) + + // The menu switch is session-only and must not alter the next startup. + preferences.isGUI_darkModeActive = false + preferences.getGUIsettingsStageSceneSizeHW()[0] = 777.0 + assertTrue(preferences.writeLayoutPreferencesToXmlFile()) + + val restored = ChatPreferences(preferencesFile) + assertTrue(restored.readPreferencesFromXmlFile()) + assertTrue(restored.isGUI_darkModeActiveByDefault) + assertFalse(restored.isGUI_darkModeActive) + } + + private fun languageStore() = LanguageStore( + storedLanguage = { SYSTEM_LANGUAGE }, + storeLanguage = { }, + systemDefault = Locale.UK, + ) +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/MainWindowStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/MainWindowStateTest.kt index 5666414e..4d745a27 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/MainWindowStateTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/MainWindowStateTest.kt @@ -120,6 +120,15 @@ class MainWindowSplitterDefaultsTest(@TempDir private val tempDir: Path) { val splitter = SplitterState(paneCount = 2, stored = stored) {} assertEquals(stored.map { it.toFloat() }, splitter.positions) } + + @Test + fun `the selected station content splitter takes the one stored profile position`() { + val stored = prefs.getGUIselectedCallSignSplitPane_dividerposition() + assertEquals(1, stored.size) + + val splitter = SplitterState(paneCount = MainWindowState.SELECTED_STATION_PANE_COUNT, stored = stored) {} + assertEquals(stored.map { it.toFloat() }, splitter.positions) + } } /** diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/ManualQtfHeadingSynchronizerTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/ManualQtfHeadingSynchronizerTest.kt new file mode 100644 index 00000000..932f3d00 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/ManualQtfHeadingSynchronizerTest.kt @@ -0,0 +1,55 @@ +package kst4contest.view.compose + +import kst4contest.model.ChatPreferences +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path + +class ManualQtfHeadingSynchronizerTest(@param:TempDir private val directory: Path) { + + @Test + fun `valid explicit QTF updates actual heading when synchronization is enabled`() { + val prefs = preferences().apply { + actualQTF.set(20.0) + } + assertTrue(applyExplicitQtfHeading(prefs, 315.5)) + assertEquals(315.5, prefs.actualQTF.get(), 0.0001) + assertEquals(135.0, prefs.stn_qtfDefault, 0.0001) + } + + @Test + fun `primary rotator prevents manual QTF synchronization`() { + val prefs = preferences().apply { + setStn_pstRotatorEnabled(true) + actualQTF.set(82.0) + } + assertFalse(applyExplicitQtfHeading(prefs, 180.0)) + assertEquals(82.0, prefs.actualQTF.get(), 0.0001) + } + + @Test + fun `disabled option and invalid angles leave actual heading unchanged`() { + val prefs = preferences().apply { + setStn_useQtfFilterDirectionAsAntennaHeading(false) + actualQTF.set(82.0) + } + listOf(90.0, Double.NaN, Double.POSITIVE_INFINITY, -0.1, 360.1).forEach { qtf -> + assertFalse(applyExplicitQtfHeading(prefs, qtf), "unexpectedly accepted $qtf") + assertEquals(82.0, prefs.actualQTF.get(), 0.0001) + } + } + + @Test + fun `zero and 360 follow the existing antenna heading convention`() { + val prefs = preferences() + assertTrue(applyExplicitQtfHeading(prefs, 0.0)) + assertEquals(0.0, prefs.actualQTF.get(), 0.0001) + assertTrue(applyExplicitQtfHeading(prefs, 360.0)) + assertEquals(360.0, prefs.actualQTF.get(), 0.0001) + } + + private fun preferences() = ChatPreferences(directory.resolve("preferences.xml")) +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/MapReachabilityStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/MapReachabilityStateTest.kt new file mode 100644 index 00000000..2fec3251 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/MapReachabilityStateTest.kt @@ -0,0 +1,66 @@ +package kst4contest.view.compose + +import kst4contest.model.Band +import kst4contest.model.ChatCategory +import kst4contest.model.ChatMember +import kst4contest.view.map.PathAnalysisResult +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class MapReachabilityStateTest { + + @Test + fun `latest station and band request rejects stale results`() { + val callbacks = mutableListOf<(PathAnalysisResult) -> Unit>() + val state = MapReachabilityState("JO51AA") { _, _, callback -> callbacks += callback } + val first = member("DL1ABC", 2, "JN49AA") + val second = member("DL2XYZ", 3, "JO60AA") + + assertTrue(state.calculate(first, Band.B_144)) + assertEquals("Loading", state.result?.analysisMode()) + assertTrue(state.calculate(second, Band.B_432)) + + callbacks[1](result("DL2XYZ", 432.0)) + callbacks[0](result("DL1ABC", 144.0)) + + assertEquals("DL2XYZ", state.result?.toCallsignRaw()) + assertEquals(432.0, state.result?.analysisFrequencyMHz()) + assertFalse(state.running) + } + + @Test + fun `selecting another station invalidates an outstanding explicit result`() { + lateinit var callback: (PathAnalysisResult) -> Unit + val state = MapReachabilityState("JO51AA") { _, _, result -> callback = result } + val first = member("DL1ABC", 2, "JN49AA") + + state.calculate(first, Band.B_144) + state.selectionChanged(StationIdentity("DL2XYZ", 2)) + callback(result("DL1ABC", 144.0)) + + assertNull(state.result) + assertFalse(state.running) + } + + @Test + fun `missing station or band does not start a request`() { + val state = MapReachabilityState("JO51AA") { _, _, _ -> error("must not run") } + + assertFalse(state.calculate(null, Band.B_144)) + assertFalse(state.calculate(member("DL1ABC", 2, "JN49AA"), null)) + } + + private fun member(callSign: String, category: Int, locator: String) = ChatMember().apply { + this.callSign = callSign + chatCategory = ChatCategory(category) + qra = locator + } + + private fun result(callSign: String, frequency: Double) = PathAnalysisResult.noProfile( + "Calculated", "JO51AA", "JN49AA", callSign, + 100.0, 180.0, 10.0, 10.0, frequency, "done", + ) +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/MessageTabsScreenTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/MessageTabsScreenTest.kt new file mode 100644 index 00000000..41378d3d --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/MessageTabsScreenTest.kt @@ -0,0 +1,118 @@ +package kst4contest.view.compose + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsNotSelected +import androidx.compose.ui.test.assertIsSelected +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import kst4contest.model.ChatCategory +import kst4contest.model.ChatMember +import kst4contest.model.ChatMessage +import kst4contest.model.ClusterMessage +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalTestApi::class) +class MessageTabsScreenTest { + + @Test + fun `tab semantics distinguish the active tab and follow selection`() = runComposeUiTest { + setContent { + Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) { + MessageTabs( + publicMessages = messageTable(), + clusterMessages = clusterTable(), + qsoOfTheOther = messageTable(), + ) + } + } + + onNodeWithText("Public messages").assertIsSelected() + onNodeWithText("DXCluster messages").assertIsNotSelected().performClick() + waitForIdle() + onNodeWithText("Public messages").assertIsNotSelected() + onNodeWithText("DXCluster messages").assertIsSelected() + } + + @Test + fun `a public message row reports its sender selection source`() = runComposeUiTest { + val public = messageTable() + val message = messageFrom("DL1PUBLIC") + public.replaceRows(listOf(message)) + val clicks = mutableListOf>() + + setContent { + Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { + MessageTabs( + publicMessages = public, + clusterMessages = clusterTable(), + qsoOfTheOther = messageTable(), + onStationMessageClicked = { clicked, source -> clicks += clicked to source }, + ) + } + } + waitForIdle() + + onNodeWithText("DL1PUBLIC").performClick() + waitForIdle() + + assertSame(message, clicks.single().first) + assertEquals(StationSelectionSource.PUBLIC_MESSAGE, clicks.single().second) + } + + @Test + fun `a qso of the other row reports its sender selection source`() = runComposeUiTest { + val qso = messageTable() + val message = messageFrom("DL2QSO") + qso.replaceRows(listOf(message)) + val clicks = mutableListOf>() + + setContent { + Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { + MessageTabs( + publicMessages = messageTable(), + clusterMessages = clusterTable(), + qsoOfTheOther = qso, + onStationMessageClicked = { clicked, source -> clicks += clicked to source }, + ) + } + } + waitForIdle() + + onNodeWithText("QSO of the other").performClick() + waitForIdle() + onNodeWithText("DL2QSO").performClick() + waitForIdle() + + assertSame(message, clicks.single().first) + assertEquals(StationSelectionSource.QSO_OF_THE_OTHER, clicks.single().second) + } + + private fun messageTable() = DataTableState( + columns = listOf( + DataColumn( + id = "sender", + title = "Sender", + value = { message: ChatMessage -> message.sender?.callSign.orEmpty() }, + ), + ), + rowKey = RowKeys.byReference(), + ) + + private fun clusterTable() = DataTableState( + columns = emptyList>(), + rowKey = RowKeys.byReference(), + ) + + private fun messageFrom(callSign: String): ChatMessage = ChatMessage().apply { + sender = ChatMember().apply { + this.callSign = callSign + chatCategory = ChatCategory(2) + } + receiver = ChatMember().apply { this.callSign = "ALL" } + chatCategory = ChatCategory(2) + messageText = "test" + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/NewJoinedBarScreenTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/NewJoinedBarScreenTest.kt new file mode 100644 index 00000000..73ef2776 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/NewJoinedBarScreenTest.kt @@ -0,0 +1,126 @@ +package kst4contest.view.compose + +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kst4contest.model.ChatCategory +import kst4contest.model.ChatMember +import kst4contest.model.RecentChatJoin +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalTestApi::class) +class NewJoinedBarScreenTest { + + @Test + fun fiveNewestStationsAreDirectAndAdditionalStationsUseTheMoreDropdown() = runComposeUiTest { + val selection = StationSelectionState() + val members = (1..7).map { member("DL${it}ABC") } + val newJoined = NewJoinedState(clock = { 10_000L }) { member -> + selection.select(member, StationSelectionSource.NEW_JOINED) + }.apply { + updateActiveMembers(members) + updateEvents( + members.mapIndexed { index, member -> + RecentChatJoin(member.callSign, 2, 9_000L + index) + }, + ) + } + val priority = TopPriorityState(topStations = { emptyList() }, onSelect = {}) + + setContent { + Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { + TopPriorityBar( + state = priority, + newJoined = newJoined, + onShowMore = {}, + ) + } + } + + onNodeWithText("New joined:").assertIsDisplayed() + listOf("DL7ABC", "DL6ABC", "DL5ABC", "DL4ABC", "DL3ABC").forEach { + onNodeWithText(it).assertIsDisplayed() + } + onNodeWithText("DL2ABC").assertDoesNotExist() + + onNodeWithTag(NEW_JOINED_MORE_TAG).performClick() + onNodeWithText("DL2ABC").assertIsDisplayed().performClick() + + assertSame(members[1], selection.selected) + assertEquals(StationSelectionSource.NEW_JOINED, selection.lastSource) + assertTrue(selection.scrollRequestRevision > 0L) + } + + @Test + fun emptyNewJoinedStateUsesNoSpaceBesidePriority() = runComposeUiTest { + val newJoined = NewJoinedState(onSelect = {}) + val priorityMember = member("OK1FPR") + val priority = TopPriorityState( + topStations = { listOf(priorityMember to 767.0) }, + onSelect = {}, + ).apply { refresh() } + + setContent { + Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) { + TopPriorityBar( + state = priority, + newJoined = newJoined, + onShowMore = {}, + ) + } + } + + onNodeWithText("New joined:").assertDoesNotExist() + onNodeWithText("Priority:").assertIsDisplayed() + onNodeWithText("1 OK1FPR 767").assertIsDisplayed() + } + + @Test + fun narrowBarMovesAdditionalDirectEntriesIntoTheSameDropdownWithoutOverflow() = runComposeUiTest { + val members = (1..7).map { member("DL${it}ABC") } + val newJoined = NewJoinedState(clock = { 10_000L }, onSelect = {}).apply { + updateActiveMembers(members) + updateEvents(members.mapIndexed { index, member -> + RecentChatJoin(member.callSign, 2, 9_000L + index) + }) + } + + setContent { + Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) { + Box(Modifier.width(300.dp)) { + TopPriorityBar( + state = TopPriorityState(topStations = { emptyList() }, onSelect = {}), + newJoined = newJoined, + onShowMore = {}, + ) + } + } + } + + onNodeWithText("DL7ABC").assertIsDisplayed() + onNodeWithText("DL6ABC").assertDoesNotExist() + val limit = with(density) { 300.dp.toPx() } + listOf(NEW_JOINED_GROUP_TAG, PRIORITY_GROUP_TAG).forEach { tag -> + val bounds = onNodeWithTag(tag).assertIsDisplayed().fetchSemanticsNode().boundsInRoot + assertTrue(bounds.right <= limit + 0.5f, "$tag crosses the narrow bar") + } + + onNodeWithTag(NEW_JOINED_MORE_TAG).performClick() + onNodeWithText("DL6ABC").assertIsDisplayed() + } + + private fun member(callSign: String) = ChatMember().apply { + this.callSign = callSign + chatCategory = ChatCategory(2) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/NewJoinedStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/NewJoinedStateTest.kt new file mode 100644 index 00000000..d0e97c8f --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/NewJoinedStateTest.kt @@ -0,0 +1,115 @@ +package kst4contest.view.compose + +import kst4contest.model.ChatCategory +import kst4contest.model.ChatMember +import kst4contest.model.RecentChatJoin +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.util.concurrent.atomic.AtomicLong + +class NewJoinedStateTest { + + private val now = AtomicLong(1_000_000L) + private var selected: ChatMember? = null + private val state = NewJoinedState(clock = now::get, onSelect = { selected = it }) + + @Test + fun `an initial active roster without live events produces no entries`() { + state.updateActiveMembers(listOf(member("HB9SJV", 2))) + + assertTrue(state.entries.isEmpty()) + } + + @Test + fun `a live join is shown once and duplicate events do not renew it`() { + val station = member("HB9SJV", 2) + state.updateActiveMembers(listOf(station)) + state.updateEvents( + listOf( + event("HB9SJV", 2, now.get() - 2_000L), + event("HB9SJV", 2, now.get() - 1_000L), + ), + ) + + assertEquals(listOf("HB9SJV"), state.entries.map { it.label }) + assertEquals(now.get() - 2_000L, state.entries.single().joinedAtEpochMillis) + } + + @Test + fun `the same full callsign in two categories stays distinguishable`() { + val main = member("DL1KDA", 2) + val microwave = member("DL1KDA", 3) + state.updateActiveMembers(listOf(main, microwave)) + state.updateEvents( + listOf( + event("DL1KDA", 3, now.get()), + event("DL1KDA", 2, now.get() - 1L), + ), + ) + + assertEquals(listOf(3, 2), state.entries.map { it.identity.categoryNumber }) + state.entries.first().select() + assertSame(microwave, selected) + } + + @Test + fun `an entry expires exactly ten minutes after the join`() { + val station = member("OE5SOS", 2) + state.updateActiveMembers(listOf(station)) + state.updateEvents(listOf(event("OE5SOS", 2, now.get()))) + + now.addAndGet(NewJoinedState.VALIDITY_MILLIS - 1L) + state.refresh() + assertTrue(state.isRecent(station)) + + now.incrementAndGet() + state.refresh() + assertFalse(state.isRecent(station)) + assertTrue(state.entries.isEmpty()) + } + + @Test + fun `only five newest entries are direct and the rest remain in the overflow`() { + val members = (1..7).map { member("DL${it}ABC", 2) } + state.updateActiveMembers(members) + state.updateEvents( + members.mapIndexed { index, member -> + event(member.callSign, 2, now.get() - (members.size - index)) + }, + ) + + assertEquals(listOf("DL7ABC", "DL6ABC", "DL5ABC", "DL4ABC", "DL3ABC"), + state.visibleEntries.map { it.label }) + assertEquals(listOf("DL2ABC", "DL1ABC"), state.overflowEntries.map { it.label }) + } + + @Test + fun `a station leaving disappears and a real rejoin gets the new event`() { + val first = member("PA2M", 2) + state.updateActiveMembers(listOf(first)) + state.updateEvents(listOf(event("PA2M", 2, now.get()))) + + state.updateActiveMembers(emptyList()) + assertTrue(state.entries.isEmpty()) + + val rejoined = member("PA2M", 2) + now.addAndGet(5_000L) + state.updateActiveMembers(listOf(rejoined)) + state.updateEvents(listOf(event("PA2M", 2, now.get()))) + + state.entries.single().select() + assertSame(rejoined, selected) + assertEquals(now.get(), state.entries.single().joinedAtEpochMillis) + } + + private fun member(callSign: String, categoryNumber: Int) = ChatMember().apply { + this.callSign = callSign + chatCategory = ChatCategory(categoryNumber) + } + + private fun event(callSign: String, categoryNumber: Int, joinedAt: Long) = + RecentChatJoin(callSign, categoryNumber, joinedAt) +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/PaletteStoreFactoryTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/PaletteStoreFactoryTest.kt index 58bd0230..914f9c33 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/PaletteStoreFactoryTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/PaletteStoreFactoryTest.kt @@ -100,11 +100,11 @@ class PaletteStoreFactoryTest(@TempDir private val tempDir: Path) { val files = OperatorProfilePaletteFiles.of(other) assertTrue( - files.dayFile.path.endsWith("profiles/OP2/KST4ContestDefaultDay.css"), + files.dayFile.toPath().endsWith(Path.of("profiles", "OP2", "KST4ContestDefaultDay.css")), "the daylight file is ${files.dayFile.path}", ) assertTrue( - files.eveningFile.path.endsWith("profiles/OP2/KST4ContestDefaultEvening.css"), + files.eveningFile.toPath().endsWith(Path.of("profiles", "OP2", "KST4ContestDefaultEvening.css")), "the evening file is ${files.eveningFile.path}", ) } diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/ReachabilityStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/ReachabilityStateTest.kt new file mode 100644 index 00000000..852e7fe9 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/ReachabilityStateTest.kt @@ -0,0 +1,63 @@ +package kst4contest.view.compose + +import kst4contest.model.Band +import kst4contest.model.ChatMember +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.util.EnumSet + +class ReachabilityStateTest { + + @Test + fun `auto and only locally active bands are offered`() { + val state = ReachabilityState(EnumSet.of(Band.B_144, Band.B_1296)) + + assertEquals(listOf(null, Band.B_144, Band.B_1296), state.options.map { it.band }) + assertNull(state.bandOverride) + assertEquals("Auto", state.selectedOption.label) + } + + @Test + fun `band changes redraw but never calculate`() { + val station = ChatMember().apply { callSign = "DL1ABC" } + val calculated = mutableListOf>() + var redraws = 0 + val state = ReachabilityState( + activeBands = EnumSet.of(Band.B_144, Band.B_432), + resolveAutoBand = { Band.B_144 }, + selectedStation = { station }, + calculate = { member, band -> calculated += member to band }, + refresh = { redraws++ }, + ) + + state.selectBand(Band.B_432) + + assertEquals(Band.B_432, state.resolveBand(station)) + assertEquals(1, redraws) + assertTrue(calculated.isEmpty()) + + assertTrue(state.calculateSelected()) + assertEquals(listOf(station to Band.B_432), calculated) + } + + @Test + fun `auto uses shared resolver and unavailable selections do not calculate`() { + var selected: ChatMember? = ChatMember() + var calculations = 0 + val state = ReachabilityState( + activeBands = EnumSet.of(Band.B_144), + resolveAutoBand = { null }, + selectedStation = { selected }, + calculate = { _, _ -> calculations++ }, + ) + + assertFalse(state.canCalculate) + assertFalse(state.calculateSelected()) + selected = null + assertFalse(state.calculateSelected()) + assertEquals(0, calculations) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/SelectedStationMessagePredicateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/SelectedStationMessagePredicateTest.kt new file mode 100644 index 00000000..0fbe30f4 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/SelectedStationMessagePredicateTest.kt @@ -0,0 +1,155 @@ +package kst4contest.view.compose + +import kst4contest.model.ChatCategory +import kst4contest.model.ChatMember +import kst4contest.model.ChatMessage +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SelectedStationMessagePredicateTest { + + private val ownCall = "DL0OWN" + private val selected = member("DL1SEL", 2) + + @Test + fun `pm to me contains both private directions with the selected station`() { + val predicate = SelectedStationMessagePredicate.forSelection( + SelectedMessageFilter.PM_TO_ME, + selected, + ownCall, + ) + + assertTrue(predicate.test(message(selected, member(ownCall, 2), 2))) + assertTrue(predicate.test(message(member(ownCall, 2), selected, 2))) + assertFalse(predicate.test(message(selected, member("DL2OTHER", 2), 2))) + assertFalse(predicate.test(publicMessage(selected, 2))) + } + + @Test + fun `pm to me also contains an explicitly addressed public message`() { + val predicate = SelectedStationMessagePredicate.forSelection( + SelectedMessageFilter.PM_TO_ME, + selected, + ownCall, + ) + + assertTrue(predicate.test(publicMessage(selected, 2, addressedToMe = true))) + assertFalse(predicate.test(publicMessage(selected, 2, addressedToMe = false))) + + val mentionOnly = publicMessage(selected, 2).apply { + messageText = "Please ask $ownCall whether the path is open" + } + assertFalse(predicate.test(mentionOnly)) + } + + @Test + fun `pm to other contains only private messages sent by the selected station to others`() { + val other = member("DL2OTHER", 2) + val predicate = SelectedStationMessagePredicate.forSelection( + SelectedMessageFilter.PM_TO_OTHER, + selected, + ownCall, + ) + + assertTrue(predicate.test(message(selected, other, 2))) + assertFalse(predicate.test(message(other, selected, 2))) + assertFalse(predicate.test(message(selected, member(ownCall, 2), 2))) + assertFalse(predicate.test(publicMessage(selected, 2))) + } + + @Test + fun `public contains only public messages sent by the selected station`() { + val predicate = SelectedStationMessagePredicate.forSelection( + SelectedMessageFilter.PUBLIC, + selected, + ownCall, + ) + + assertTrue(predicate.test(publicMessage(selected, 2))) + assertTrue(predicate.test(publicMessage(selected, 2, addressedToMe = true))) + assertFalse(predicate.test(publicMessage(member("DL2OTHER", 2), 2))) + assertFalse(predicate.test(message(selected, member("DL2OTHER", 2), 2))) + } + + @Test + fun `nothing applies no additional message type restriction`() { + val predicate = SelectedStationMessagePredicate.forSelection( + SelectedMessageFilter.NOTHING, + selected, + ownCall, + ) + + assertTrue(predicate.test(message(selected, member(ownCall, 2), 2))) + assertTrue(predicate.test(message(member(ownCall, 2), selected, 2))) + assertTrue(predicate.test(publicMessage(selected, 2))) + assertFalse(predicate.test(publicMessage(member("DL2OTHER", 2), 2))) + } + + @Test + fun `the same callsign in another category does not match`() { + val predicate = SelectedStationMessagePredicate.forSelection( + SelectedMessageFilter.PUBLIC, + selected, + ownCall, + ) + + assertFalse(predicate.test(publicMessage(member("DL1SEL", 3), 3))) + } + + @Test + fun `message category is authoritative when a member snapshot has no category`() { + val senderWithoutCategory = member("DL1SEL", 2).apply { chatCategory = null } + val predicate = SelectedStationMessagePredicate.forSelection( + SelectedMessageFilter.PM_TO_ME, + selected, + ownCall, + ) + + assertTrue(predicate.test(message(senderWithoutCategory, member(ownCall, 2), 2))) + } + + @Test + fun `missing selection or incomplete historical data fails closed`() { + val withoutSelection = SelectedStationMessagePredicate.forSelection( + SelectedMessageFilter.PUBLIC, + null, + ownCall, + ) + val incomplete = ChatMessage().apply { chatCategory = ChatCategory(2) } + + assertFalse(withoutSelection.test(publicMessage(selected, 2))) + assertFalse( + SelectedStationMessagePredicate.forSelection(SelectedMessageFilter.PUBLIC, selected, ownCall) + .test(incomplete), + ) + assertFalse( + SelectedStationMessagePredicate.forSelection( + SelectedMessageFilter.PUBLIC, + ChatMember().apply { callSign = "DL1SEL" }, + ownCall, + ).test(publicMessage(selected, 2)), + ) + } + + private fun message(sender: ChatMember, receiver: ChatMember, category: Int): ChatMessage = ChatMessage().apply { + this.sender = sender + this.receiver = receiver + chatCategory = ChatCategory(category) + messageText = "test" + } + + private fun publicMessage( + sender: ChatMember, + category: Int, + addressedToMe: Boolean = false, + ): ChatMessage = message(sender, member("ALL", category), category).apply { + isMessageDirectedToCommunity = true + isMessageDirectedToMe = addressedToMe + } + + private fun member(callSign: String, category: Int): ChatMember = ChatMember().apply { + this.callSign = callSign + chatCategory = ChatCategory(category) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/SelectedStationMessageUiVisualRenderingTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/SelectedStationMessageUiVisualRenderingTest.kt new file mode 100644 index 00000000..dd312fed --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/SelectedStationMessageUiVisualRenderingTest.kt @@ -0,0 +1,158 @@ +package kst4contest.view.compose + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import kst4contest.model.Band +import kst4contest.model.ChatCategory +import kst4contest.model.ChatMember +import kst4contest.model.ChatMessage +import kst4contest.model.ClusterMessage +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.nio.file.Files +import java.nio.file.Path +import java.time.Instant +import java.util.EnumSet +import java.util.function.Consumer + +/** Produces review images for the selected-station controls and lower message tabs. */ +class SelectedStationMessageUiVisualRenderingTest { + + @Test + fun rendersDayAndEveningAt100And125Percent() { + val reportDirectory = Path.of("build", "reports", "selected-station-message-ui") + Files.createDirectories(reportDirectory) + + val scenarios = listOf( + RenderScenario(prefix = "", panelWidth = 570.dp, windowHeight = 768), + RenderScenario(prefix = "narrow-", panelWidth = 360.dp, windowHeight = 768), + RenderScenario(prefix = "short-", panelWidth = 570.dp, windowHeight = 480), + ) + scenarios.forEach { scenario -> + listOf(false, true).forEach { darkMode -> + listOf(1f, 1.25f).forEach { scale -> + val bytes = render(darkMode, scale, scenario.panelWidth, scenario.windowHeight) + val palette = if (darkMode) "evening" else "day" + val scaling = if (scale == 1f) "100" else "125" + val name = "${scenario.prefix}$palette-$scaling.png" + Files.write(reportDirectory.resolve(name), bytes) + assertTrue(bytes.size > 10_000, "$name did not render") + } + } + } + } + + private fun render(darkMode: Boolean, scale: Float, panelWidth: Dp, windowHeight: Int): ByteArray { + val selectedMember = member("DM5M", 2).apply { + setQTFdirection(255.54) + qrb = 1907.59 + activityTimeLastInEpoch = Instant.now().epochSecond - 240 + isQrv144 = false + isQrv1240 = false + } + val selectedState = SelectedStationState( + propagateNotQrv = {}, + createSked = { _, _, _, _ -> true }, + detectBands = { "Bands: 144/432 MHz" }, + ).apply { + select(selectedMember) + skedBand = Band.B_144 + skedMode = "SSB" + remindPm = true + } + val selectedMessages = messageTable().apply { + replaceRows( + listOf( + message(selectedMember, member("DL0OWN", 2), "pse ur qrg"), + message(selectedMember, member("ALL", 2), "(DL0OWN) qrv 144.300"), + ), + ) + } + val publicMessages = messageTable().apply { + replaceRows(listOf(message(selectedMember, member("ALL", 2), "cq 144.300"))) + } + val qsoMessages = messageTable().apply { + replaceRows(listOf(message(member("DL1ABC", 2), member("DL2XYZ", 2), "pse sked"))) + } + + val scene = ImageComposeScene(width = 1366, height = windowHeight, density = Density(scale)) { + Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + Row(Modifier.fillMaxSize().padding(6.dp)) { + MessageTabs( + publicMessages = publicMessages, + clusterMessages = clusterTable(), + qsoOfTheOther = qsoMessages, + modifier = Modifier.weight(1f), + ) + SelectedStationPanel( + state = selectedState, + messages = selectedMessages, + activeBands = EnumSet.of(Band.B_144, Band.B_432, Band.B_1296), + openInBrowser = Consumer {}, + onShowOnMap = {}, + onShowPathInAirScout = {}, + modifier = Modifier.width(panelWidth).padding(start = 6.dp), + ) + } + } + } + } + + return try { + scene.render().encodeToData()!!.bytes + } finally { + scene.close() + } + } + + private fun messageTable() = DataTableState( + columns = listOf( + DataColumn("sender", "Sender", value = { message: ChatMessage -> message.sender.callSign }), + DataColumn("message", "Message", value = { message: ChatMessage -> message.messageText.orEmpty() }), + ), + rowKey = RowKeys.byReference(), + ) + + private fun clusterTable() = DataTableState( + columns = listOf( + DataColumn( + "message", + "DXCluster", + value = { message: ClusterMessage -> message.messageInhibited.orEmpty() }, + ), + ), + rowKey = RowKeys.byReference(), + ) + + private fun message(sender: ChatMember, receiver: ChatMember, text: String) = ChatMessage().apply { + this.sender = sender + this.receiver = receiver + chatCategory = sender.chatCategory + messageText = text + } + + private fun member(callSign: String, category: Int) = ChatMember().apply { + this.callSign = callSign + chatCategory = ChatCategory(category) + qra = "JO31AA" + } + + private data class RenderScenario( + val prefix: String, + val panelWidth: Dp, + val windowHeight: Int, + ) +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/SelectedStationPanelScreenTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/SelectedStationPanelScreenTest.kt new file mode 100644 index 00000000..68e277ae --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/SelectedStationPanelScreenTest.kt @@ -0,0 +1,431 @@ +package kst4contest.view.compose + +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.dp +import kst4contest.model.Band +import kst4contest.model.ChatCategory +import kst4contest.model.ChatMember +import kst4contest.model.ChatMessage +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.time.Instant +import java.util.EnumSet +import java.util.function.Consumer +import kotlin.math.abs + +/** The selected-station controls remain reachable when its splitter pane is short. */ +@OptIn(ExperimentalTestApi::class) +class SelectedStationPanelScreenTest { + + @Test + fun stationHeaderKeepsAllMetadataOnOneLineWhenWideAndOmitsTheLocator() = runComposeUiTest { + val station = member("CT1EKD").apply { + qra = "IM59IB" + chatCategory = ChatCategory(2) + setQTFdirection(255.54) + qrb = 1907.59 + activityTimeLastInEpoch = Instant.now().epochSecond - 240 + } + val selected = selectedState(station, detectedBands = "Bands: 144/432 MHz") + + setPanel(selected, Modifier.width(900.dp).height(420.dp)) + + val callsignTop = onNodeWithText("CT1EKD").fetchSemanticsNode().boundsInRoot.top + listOf( + station.chatCategory.getChatCategoryName(station.chatCategory.categoryNumber), + "QTF: 255.54°", + "QRB: 1907.59 km", + "Bands: 144/432 MHz", + ).forEach { text -> + val top = onNodeWithText(text).assertIsDisplayed().fetchSemanticsNode().boundsInRoot.top + assertTrue(abs(top - callsignTop) <= 4f, "$text did not remain in the wide title row") + } + onNodeWithText("Last activity:", substring = true).assertIsDisplayed() + onNodeWithText("Bands: Bands:", substring = true).assertDoesNotExist() + onNodeWithText("IM59IB").assertDoesNotExist() + } + + @Test + fun stationHeaderWrapsMetadataWithoutClippingWhenNarrow() = runComposeUiTest { + val station = member("CT1EKD").apply { + qra = "IM59IB" + chatCategory = ChatCategory(2) + setQTFdirection(255.54) + qrb = 1907.59 + activityTimeLastInEpoch = Instant.now().epochSecond - 240 + } + val selected = selectedState(station, detectedBands = "Bands: 144/432 MHz") + + setPanel(selected, Modifier.width(310.dp).height(620.dp)) + + val callsign = onNodeWithText("CT1EKD").fetchSemanticsNode().boundsInRoot + val bands = onNodeWithText("Bands: 144/432 MHz").assertIsDisplayed() + .fetchSemanticsNode().boundsInRoot + val panel = onNodeWithTag(SELECTED_STATION_PANEL_TAG).fetchSemanticsNode().boundsInRoot + assertTrue(bands.top > callsign.top, "the narrow header did not wrap") + assertTrue(bands.right <= panel.right, "wrapped metadata crossed the panel edge") + onNodeWithText("IM59IB").assertDoesNotExist() + } + + @Test + fun stationHeaderKeepsTheAlreadyLabelledEmptyBandsValueExactlyOnce() = runComposeUiTest { + val selected = selectedState(member("DM5M"), detectedBands = "Bands: -") + + setPanel(selected, Modifier.width(570.dp).height(420.dp)) + + onNodeWithText("Bands: -").assertIsDisplayed() + onNodeWithText("Bands: Bands:", substring = true).assertDoesNotExist() + } + + @Test + fun messageFilterLabelSharesTheFirstFlowItemWithPmToMe() = runComposeUiTest { + val selected = selectedState("DM5M") + + setPanel(selected, Modifier.width(250.dp).height(520.dp)) + + val label = onNodeWithText("Show messages:").assertIsDisplayed() + .fetchSemanticsNode().boundsInRoot + val firstChoice = onNodeWithText("pm to me").assertIsDisplayed() + .fetchSemanticsNode().boundsInRoot + assertTrue(abs(label.center.y - firstChoice.center.y) <= 2f) + } + + @Test + fun allMessageChoicesShareOneVerticalAxis() = runComposeUiTest { + setPanel(selectedState("DM5M"), Modifier.width(570.dp).height(420.dp)) + + val centers = listOf("pm to me", "pm to other", "public msgs", "nothing") + .map { onNodeWithText(it).fetchSemanticsNode().boundsInRoot.center.y } + + assertTrue(centers.max() - centers.min() <= 1f, "radio labels are misaligned: $centers") + } + + @Test + fun selectedMessageSplitterChangesTheActualTableHeightInBothDirections() = runComposeUiTest { + val selected = selectedState("DM5M") + val splitter = SplitterState(2, doubleArrayOf(0.45)) {} + + setContent { + Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { + SelectedStationPanel( + state = selected, + messages = messageTable(), + activeBands = EnumSet.of(Band.B_144), + contentSplitter = splitter, + openInBrowser = Consumer {}, + onShowOnMap = {}, + onShowPathInAirScout = {}, + modifier = Modifier.width(570.dp).height(620.dp), + ) + } + } + + val initial = onNodeWithTag(SELECTED_MESSAGES_TABLE_TAG) + .fetchSemanticsNode().boundsInRoot.height + runOnIdle { splitter.drag(0, 0.70f, 500f) } + waitForIdle() + val taller = onNodeWithTag(SELECTED_MESSAGES_TABLE_TAG) + .fetchSemanticsNode().boundsInRoot.height + assertTrue(taller > initial, "moving the divider down did not enlarge the table") + + runOnIdle { splitter.drag(0, 0.25f, 500f) } + waitForIdle() + val shorter = onNodeWithTag(SELECTED_MESSAGES_TABLE_TAG) + .fetchSemanticsNode().boundsInRoot.height + assertTrue(shorter < taller, "moving the divider up did not shrink the table") + + onNodeWithText("Lookup on qrz.com").assertIsDisplayed() + } + + @Test + fun selectedMessageSplitterSurvivesStationAndMessageUpdates() = runComposeUiTest { + val selected = selectedState("DM5M") + val messages = messageTable() + val splitter = SplitterState(2, doubleArrayOf(0.62)) {} + + setContent { + Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { + SelectedStationPanel( + state = selected, + messages = messages, + activeBands = EnumSet.of(Band.B_144), + contentSplitter = splitter, + openInBrowser = Consumer {}, + onShowOnMap = {}, + onShowPathInAirScout = {}, + modifier = Modifier.width(570.dp).height(620.dp), + ) + } + } + + val expectedPosition = splitter.positions.single() + runOnIdle { + selected.select(member("DL1ABC")) + messages.replaceRows(listOf(ChatMessage().apply { messageText = "new message" })) + } + waitForIdle() + + assertEquals(expectedPosition, splitter.positions.single()) + } + + @Test + fun notQrvHeadingFollowsTheCurrentStationWithoutKeepingAStaleCallsign() = runComposeUiTest { + val selected = selectedState("DM5M") + + setContent { + Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { + SelectedStationPanel( + state = selected, + messages = messageTable(), + activeBands = EnumSet.of(Band.B_144), + openInBrowser = Consumer {}, + onShowOnMap = {}, + onShowPathInAirScout = {}, + modifier = Modifier.height(420.dp), + ) + } + } + + onNodeWithText("Tag DM5M not QRV at bands:").assertIsDisplayed() + + runOnIdle { selected.select(member("DL1ABC")) } + waitForIdle() + onNodeWithText("Tag DM5M not QRV at bands:").assertDoesNotExist() + onNodeWithText("Tag DL1ABC not QRV at bands:").assertIsDisplayed() + + runOnIdle { selected.select(null) } + waitForIdle() + onNodeWithText("Tag DL1ABC not QRV at bands:").assertDoesNotExist() + } + + @Test + fun skedSentenceUsesTheCompactControlsWithoutTheOldOffsetsLabel() = runComposeUiTest { + val selected = selectedState("DM5M") + + setContent { + Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) { + SelectedStationPanel( + state = selected, + messages = messageTable(), + activeBands = EnumSet.of(Band.B_144), + openInBrowser = Consumer {}, + onShowOnMap = {}, + onShowPathInAirScout = {}, + modifier = Modifier.height(420.dp), + ) + } + } + + onNodeWithText("Sked alert").assertIsDisplayed() + onNodeWithText("Sked in").assertIsDisplayed() + onNodeWithText("Remind pm in").assertIsDisplayed() + onNodeWithText("Reminder offsets").assertDoesNotExist() + } + + @Test + fun skedBandChoiceUsesEnabledBandsAndPassesTheSelectionToSkedCreation() = runComposeUiTest { + var createdMinutes: Int? = null + var createdBand: Band? = null + var createdMode: String? = null + val selected = SelectedStationState( + propagateNotQrv = {}, + createSked = { _, minutes, band, mode -> + createdMinutes = minutes + createdBand = band + createdMode = mode + true + }, + ).apply { + select(member("DM5M")) + skedBand = Band.B_144 + skedMode = "SSB" + } + + setContent { + Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { + SelectedStationPanel( + state = selected, + messages = messageTable(), + activeBands = EnumSet.of(Band.B_144, Band.B_432), + openInBrowser = Consumer {}, + onShowOnMap = {}, + onShowPathInAirScout = {}, + modifier = Modifier.width(570.dp).height(520.dp), + ) + } + } + + onNodeWithText("144 MHz").performClick() + onNodeWithText("1296 MHz").assertDoesNotExist() + onNodeWithText("432 MHz").assertIsDisplayed().performClick() + assertEquals(Band.B_432, selected.skedBand) + + onNodeWithText("5").performClick() + onNodeWithText("8").assertIsDisplayed().performClick() + onNodeWithText("SSB").performClick() + onNodeWithText("CW").assertIsDisplayed().performClick() + + onNodeWithText("Create sked").performScrollTo().performClick() + assertEquals(8, createdMinutes) + assertEquals(Band.B_432, createdBand) + assertEquals("CW", createdMode) + } + + @Test + fun notQrvToggleButtonsKeepAllSemanticsAndFollowTheSelectedStation() = runComposeUiTest { + val first = member("DM5M").apply { + isQrv144 = false + isQrv432 = true + isQrv1240 = false + } + val second = member("DL1ABC").apply { + isQrv144 = true + isQrv432 = false + isQrv1240 = true + } + val propagated = mutableListOf() + val selected = SelectedStationState( + propagateNotQrv = { propagated += it }, + createSked = { _, _, _, _ -> true }, + ).apply { select(first) } + + setPanel( + selected, + Modifier.width(570.dp).height(520.dp), + EnumSet.of(Band.B_144, Band.B_432, Band.B_1296), + ) + + onNodeWithText("144").assertIsOn() + onNodeWithText("432").assertIsOff() + onNodeWithText("23cm").assertIsOn() + onNodeWithText("all").assertIsOff().performClick().assertIsOn() + assertFalse(first.isQrv10G, "all must retain its existing all-supported-bands semantics") + assertTrue(propagated.last() === first) + + onNodeWithText("all").performClick().assertIsOff() + assertTrue(first.isQrv144) + assertTrue(first.isQrv10G) + + runOnIdle { selected.select(second) } + waitForIdle() + onNodeWithText("144").assertIsOff() + onNodeWithText("432").assertIsOn() + onNodeWithText("23cm").assertIsOff() + } + + @Test + fun notQrvAndSkedGroupsFollowTheActualPanelWidth() = runComposeUiTest { + val selected = selectedState("DM5M") + var panelWidth by mutableStateOf(620.dp) + + setContent { + Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { + Panel(selected, Modifier.width(panelWidth).height(560.dp)) + } + } + + var notQrv = onNodeWithTag(SELECTED_NOT_QRV_GROUP_TAG).fetchSemanticsNode().boundsInRoot + var sked = onNodeWithTag(SELECTED_SKED_GROUP_TAG).fetchSemanticsNode().boundsInRoot + assertTrue(abs(notQrv.top - sked.top) <= 1f, "wide groups were not arranged horizontally") + assertTrue(sked.left >= notQrv.right, "wide groups overlap") + + runOnIdle { panelWidth = 360.dp } + waitForIdle() + + notQrv = onNodeWithTag(SELECTED_NOT_QRV_GROUP_TAG).fetchSemanticsNode().boundsInRoot + sked = onNodeWithTag(SELECTED_SKED_GROUP_TAG).fetchSemanticsNode().boundsInRoot + val panel = onNodeWithTag(SELECTED_STATION_PANEL_TAG).fetchSemanticsNode().boundsInRoot + assertTrue(sked.top >= notQrv.bottom, "narrow groups were not stacked vertically") + assertTrue(notQrv.right <= panel.right && sked.right <= panel.right, "a group crossed the panel edge") + + listOf("Create sked", "Sked fail", "Reset fail").forEach { + onNodeWithText(it).performScrollTo().assertIsDisplayed() + } + } + + @Test + fun lookupActionsCanBeScrolledIntoViewInAShortPane() = runComposeUiTest { + val selected = selectedState("DL1ABC") + val messages = messageTable() + + setContent { + Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { + SelectedStationPanel( + state = selected, + messages = messages, + activeBands = EnumSet.of(Band.B_144), + openInBrowser = Consumer {}, + onShowOnMap = {}, + onShowPathInAirScout = {}, + modifier = Modifier.height(220.dp), + ) + } + } + + onNodeWithText("Lookup on qrz.com").assertIsDisplayed() + } + + private fun selectedState(callSign: String) = selectedState(member(callSign)) + + private fun selectedState(member: ChatMember, detectedBands: String = "") = SelectedStationState( + propagateNotQrv = {}, + createSked = { _, _, _, _ -> true }, + detectBands = { detectedBands }, + ).apply { select(member) } + + private fun member(callSign: String) = ChatMember().apply { + this.callSign = callSign + qra = "JO50AA" + } + + private fun androidx.compose.ui.test.ComposeUiTest.setPanel( + selected: SelectedStationState, + modifier: Modifier, + activeBands: Set = EnumSet.of(Band.B_144), + ) { + setContent { + Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { + Panel(selected, modifier, activeBands) + } + } + } + + @androidx.compose.runtime.Composable + private fun Panel( + selected: SelectedStationState, + modifier: Modifier, + activeBands: Set = EnumSet.of(Band.B_144), + ) { + SelectedStationPanel( + state = selected, + messages = messageTable(), + activeBands = activeBands, + openInBrowser = Consumer {}, + onShowOnMap = {}, + onShowPathInAirScout = {}, + modifier = modifier, + ) + } + + private fun messageTable() = DataTableState( + SelectedStationMessageColumns.all(), + RowKeys.byReference(), + "selected-station-panel-test", + ) +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/SelectedStationStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/SelectedStationStateTest.kt index 1060513d..4f9d8700 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/SelectedStationStateTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/SelectedStationStateTest.kt @@ -1,11 +1,14 @@ package kst4contest.view.compose import kst4contest.model.ChatMember +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.assertNull import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path /** * The panel below the station list, for the station currently selected. @@ -16,6 +19,9 @@ import org.junit.jupiter.api.Assertions.assertTrue */ class SelectedStationStateTest { + @TempDir + lateinit var tempDir: Path + private class Session { val propagated = mutableListOf() var skedsCreated = 0 @@ -178,6 +184,15 @@ class SelectedStationStateTest { "the JavaFX radio buttons were one toggle group; two cannot be on") } + @Test + fun `public messages can be the configured initial radio mode`() { + val prefs = ChatPreferences(tempDir.resolve("preferences.xml")).apply { + isGuiOptions_defaultFilterPublicMsgs = true + } + + assertEquals(SelectedMessageFilter.PUBLIC, SelectedMessageFilter.fromPreferences(prefs)) + } + /** * No filter is chosen until the operator chooses one. JavaFX has the * setSelected(true) on "pm to me" commented out, so the group starts with nothing on, diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/SkedEditorStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/SkedEditorStateTest.kt new file mode 100644 index 00000000..1fe14f52 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/SkedEditorStateTest.kt @@ -0,0 +1,82 @@ +package kst4contest.view.compose + +import kst4contest.model.Band +import kst4contest.model.ChatCategory +import kst4contest.model.ChatMember +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SkedEditorStateTest { + + @Test + fun `panel submission includes reminder settings in one request`() { + val requests = mutableListOf() + val member = member("DL1ABC", 2) + val state = SelectedStationState( + propagateNotQrv = { }, + createSked = { _, _, _, _ -> false }, + ) + state.skedRequestSubmitter = { request -> requests += request; true } + state.select(member) + state.skedMinutes = 8 + state.skedBand = Band.B_432 + state.skedMode = "CW" + state.remindPm = true + state.reminderOffsets = "5+2+1" + + assertTrue(state.createSked()) + assertEquals( + SkedRequest(member, 8, Band.B_432, "CW", true, "5+2+1"), + requests.single(), + ) + } + + @Test + fun `popup draft keeps its target when the shared selection changes`() { + val requests = mutableListOf() + val original = member("DL1ABC", 2) + val later = member("DL2XYZ", 3) + val draft = SkedEditorState( + target = original, + minutes = 5, + band = Band.B_144, + mode = "SSB", + remindPm = false, + reminderOffsets = "2+1", + submitter = { request -> requests += request; true }, + ) + + draft.minutes = 10 + draft.band = Band.B_1296 + draft.remindPm = true + @Suppress("UNUSED_VARIABLE") val unrelatedSelection = later + + assertTrue(draft.submit()) + assertSame(original, requests.single().target) + assertEquals(Band.B_1296, requests.single().band) + assertTrue(requests.single().remindPm) + } + + @Test + fun `rejected popup submission reports failure so popup can remain open`() { + val draft = SkedEditorState( + target = member("DL1ABC", 2), + minutes = 5, + band = null, + mode = "SSB", + remindPm = false, + reminderOffsets = "2+1", + submitter = { false }, + ) + + assertFalse(draft.submit()) + } + + private fun member(callSign: String, category: Int) = ChatMember().apply { + this.callSign = callSign + chatCategory = ChatCategory(category) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/SplitterStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/SplitterStateTest.kt index f5ff2c75..998b7da8 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/SplitterStateTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/SplitterStateTest.kt @@ -219,6 +219,57 @@ class SplitterStateTest { assertEquals(777f, state.paneSizes(totalPx = 777f).sum(), 0.01f) } + @Test + fun `pane-specific minimums keep fixed-height controls visible without replacing stored positions`() { + val stored = doubleArrayOf(0.62, 0.7, 0.75, 0.9) + val state = SplitterState(paneCount = 5, stored = stored) {} + + val sizes = state.paneSizes( + totalPx = 800f, + minimumPaneSizesPx = floatArrayOf(24f, 26f, 104f, 26f, 24f), + ) + + assertTrue(sizes[0] >= 24f) + assertTrue(sizes[1] >= 26f) + assertTrue(sizes[2] >= 104f) + assertTrue(sizes[3] >= 26f) + assertTrue(sizes[4] >= 24f) + assertEquals(800f, sizes.sum(), 0.01f) + stored.forEachIndexed { index, position -> + assertEquals(position.toFloat(), state.positions[index], 0.001f) + } + } + + @Test + fun `pane-specific minimums scale down together in an impossibly small splitter`() { + val state = SplitterState(paneCount = 3, stored = doubleArrayOf(0.2, 0.8)) {} + + val sizes = state.paneSizes( + totalPx = 100f, + minimumPaneSizesPx = floatArrayOf(50f, 100f, 50f), + ) + + assertEquals(listOf(25f, 50f, 25f), sizes) + assertEquals(100f, sizes.sum(), 0.01f) + } + + @Test + fun `pane-specific minimum scaling tolerates float rounding`() { + val state = SplitterState( + paneCount = 2, + stored = doubleArrayOf(0.48), + minPanePx = 160f, + ) {} + + val sizes = state.paneSizes( + totalPx = 24.405657f, + minimumPaneSizesPx = floatArrayOf(52f, 160f), + ) + + assertTrue(sizes.all { it >= 0f }) + assertEquals(24.405657f, sizes.sum(), 0.0001f) + } + private fun assertNull(value: Any?, message: String) { assertFalse(value != null, message) } diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/StationContextMenuStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationContextMenuStateTest.kt new file mode 100644 index 00000000..cd131d44 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationContextMenuStateTest.kt @@ -0,0 +1,90 @@ +package kst4contest.view.compose + +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection +import kst4contest.model.Band +import kst4contest.model.ChatCategory +import kst4contest.model.ChatMember +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class StationContextMenuStateTest { + + @Test + fun `single row target is selected immediately with its context source`() { + val selections = mutableListOf>() + val state = StationContextMenuState { member, source -> selections += member to source } + val member = member("DL1ABC", 2) + + state.open(listOf(member), StationSelectionSource.STATION_CONTEXT_MENU, IntOffset(10, 20)) + + assertSame(member, state.target) + assertEquals(listOf(member to StationSelectionSource.STATION_CONTEXT_MENU), selections) + } + + @Test + fun `grouped map target requires an explicit full callsign and category choice`() { + val selections = mutableListOf>() + val state = StationContextMenuState { member, source -> selections += member to source } + val main = member("DN9APW-2", 2) + val second = member("DN9APW-2", 3) + + state.open( + listOf(main, second, main), + StationSelectionSource.MAP_CONTEXT_MENU, + IntOffset(30, 40), + ) + + assertNull(state.target) + assertEquals(2, state.candidates.size) + assertEquals(emptyList>(), selections) + + state.chooseTarget(second) + + assertSame(second, state.target) + assertEquals(listOf(second to StationSelectionSource.MAP_CONTEXT_MENU), selections) + } + + @Test + fun `target label retains the full suffix and category`() { + val target = member("DN9APW-70", 3) + + val label = stationContextTargetLabel(target) + + assertTrue(label.startsWith("DN9APW-70 | ")) + } + + @Test + fun `worked menu state follows the existing per-band flags`() { + val target = member("DL1ABC", 2) + target.isWorked144 = true + + assertTrue(target.isWorkedOn(Band.B_144)) + assertFalse(target.isWorkedOn(Band.B_432)) + } + + @Test + fun `popup anchor is clamped at the window edge`() { + val provider = WindowPointPopupPositionProvider(IntOffset(990, 790)) + + val position = provider.calculatePosition( + anchorBounds = IntRect.Zero, + windowSize = IntSize(1_000, 800), + layoutDirection = LayoutDirection.Ltr, + popupContentSize = IntSize(300, 250), + ) + + assertEquals(IntOffset(700, 550), position) + } + + private fun member(callSign: String, category: Int) = ChatMember().apply { + this.callSign = callSign + chatCategory = ChatCategory(category) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/StationFilterBarScreenTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationFilterBarScreenTest.kt index 74b1e1a1..ad6d2959 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/StationFilterBarScreenTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationFilterBarScreenTest.kt @@ -10,10 +10,13 @@ import androidx.compose.ui.test.assertIsOff import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import androidx.compose.ui.test.runComposeUiTest import androidx.compose.ui.unit.dp import kst4contest.model.Band +import kst4contest.model.ChatPreferences +import kst4contest.view.compose.ObservedValue import kst4contest.view.compose.tabs.checkBeside import kst4contest.view.compose.tabs.fieldBeside import kst4contest.view.compose.tabs.showTab @@ -23,7 +26,10 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.Path import java.util.EnumSet +import kotlin.math.abs /** * The QRB part of the station filter bar, drawn (#21). @@ -35,7 +41,7 @@ import java.util.EnumSet * Headless: only a composition, no window. */ @OptIn(ExperimentalTestApi::class) -class StationFilterBarScreenTest { +class StationFilterBarScreenTest(@param:TempDir private val directory: Path) { private val maxLabel = "Show only QRB [km] <=" private val minLabel = "Show only QRB [km] >" @@ -43,6 +49,7 @@ class StationFilterBarScreenTest { private class Bar { val filters = StationFilterState() var changes = 0 + val explicitQtfDirections = mutableListOf() } private fun ComposeUiTest.showBar(): Bar { @@ -51,12 +58,127 @@ class StationFilterBarScreenTest { StationFilterBar( filters = bar.filters, activeBands = EnumSet.of(Band.B_144), + reachability = ReachabilityState(EnumSet.of(Band.B_144)), onChanged = { bar.changes++ }, + onQtfDirectionChanged = { bar.explicitQtfDirections += it }, ) } return bar } + @Test + fun aValidManualQtfEntryIsReportedEvenWhileTheFilterIsOff() = runComposeUiTest { + val bar = showBar() + + type("Show only QTF:", "127.5") + + assertFalse(bar.filters.qtfEnabled) + assertEquals(listOf(127.5), bar.explicitQtfDirections) + } + + @Test + fun everyBearingButtonReportsItsExplicitDirection() = runComposeUiTest { + val bar = showBar() + + Bearing.entries.forEach { bearing -> + onNodeWithText(bearing.name).performClick() + } + + assertEquals(Bearing.entries.map { it.degrees }, bar.explicitQtfDirections) + } + + @Test + fun deselectingABearingDoesNotReportAnotherExplicitDirection() = runComposeUiTest { + val bar = showBar() + + onNodeWithText("NE").performClick() + onNodeWithText("NE").performClick() + + assertFalse(bar.filters.qtfEnabled) + assertEquals(listOf(45.0), bar.explicitQtfDirections) + } + + @Test + fun defaultManualHeadingMakesNorthEastVisibleToTheMapObserverWhileFilterIsOff() = runComposeUiTest { + val prefs = ChatPreferences(directory.resolve("preferences.xml")) + val filters = StationFilterState() + val mapHeading = ObservedValue(prefs.actualQTF) + try { + showTab { + StationFilterBar( + filters = filters, + activeBands = EnumSet.of(Band.B_144), + reachability = ReachabilityState(EnumSet.of(Band.B_144)), + onChanged = {}, + onQtfDirectionChanged = { applyExplicitQtfHeading(prefs, it) }, + ) + } + + assertFalse(filters.qtfEnabled) + onNodeWithText("NE").performClick() + waitForIdle() + + assertEquals(45.0, prefs.actualQTF.get(), 0.0001) + assertEquals(45.0, requireNotNull(mapHeading.value), 0.0001) + assertEquals(135.0, prefs.stn_qtfDefault, 0.0001) + } finally { + mapHeading.release() + } + } + + @Test + fun resetAndQtfCheckboxChangesDoNotReportAnExplicitDirection() = runComposeUiTest { + val bar = showBar() + toggle("Show only QTF:") + onNodeWithText("Reset filters").performClick() + + assertTrue(bar.explicitQtfDirections.isEmpty()) + } + + @Test + fun invalidManualQtfEntriesAreNotReported() = runComposeUiTest { + val bar = showBar() + + listOf("NaN", "Infinity", "-1", "360.1").forEach { type("Show only QTF:", it) } + + assertTrue(bar.explicitQtfDirections.isEmpty()) + assertEquals(0.0, bar.filters.qtfDegrees, 0.0001) + } + + @Test + fun reachabilityBandChangesDoNotCalculateUntilTheButtonIsClicked() = runComposeUiTest { + val filters = StationFilterState() + val station = kst4contest.model.ChatMember() + var calculations = 0 + val reachability = ReachabilityState( + activeBands = EnumSet.of(Band.B_144, Band.B_432), + resolveAutoBand = { Band.B_144 }, + selectedStation = { station }, + calculate = { member, band -> + assertEquals(station, member) + assertEquals(Band.B_432, band) + calculations++ + }, + ) + + showTab { + StationFilterBar( + filters = filters, + activeBands = EnumSet.of(Band.B_144, Band.B_432), + reachability = reachability, + onChanged = { }, + ) + } + + onNodeWithText("Reachability").assertIsDisplayed() + onNodeWithText("Auto").performClick() + awaitNode(inPopup("432")).performClick() + assertEquals(0, calculations) + + onNodeWithText("Calc selected").performClick() + assertEquals(1, calculations) + } + @Test fun theMinimumQrbControlsWriteTheMinimumFilterOnly() = runComposeUiTest { val bar = showBar() @@ -133,7 +255,12 @@ class StationFilterBarScreenTest { setContent { Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { Box(Modifier.width(width)) { - StationFilterBar(filters = filters, activeBands = EnumSet.of(Band.B_144), onChanged = {}) + StationFilterBar( + filters = filters, + activeBands = EnumSet.of(Band.B_144), + reachability = ReachabilityState(EnumSet.of(Band.B_144)), + onChanged = {}, + ) } } } @@ -145,6 +272,10 @@ class StationFilterBarScreenTest { onNodeWithText(maxLabel), onNodeWithText(minLabel), onNodeWithText("Show only QTF:"), + onNodeWithText("Inactive stations"), + onNodeWithText("Only new grids"), + onNodeWithText("AS next 5m"), + onNodeWithText("Calc selected"), fieldBeside(maxLabel), fieldBeside(minLabel), ) @@ -154,4 +285,61 @@ class StationFilterBarScreenTest { assertTrue(right <= limit + 0.5f, "a control ends at $right px, beyond the $limit px wide bar") } } + + @Test + fun filterControlsShareOneHeightAndBearingsShareOneCompactWidth() = runComposeUiTest { + showBar() + + val fieldHeight = fieldBeside(maxLabel).fetchSemanticsNode().boundsInRoot.height + val checkboxHeight = checkBeside(maxLabel).fetchSemanticsNode().boundsInRoot.height + val findFieldHeight = onNodeWithText("Find...").fetchSemanticsNode().boundsInRoot.height + val controlHeights = listOf("Reset filters", "N", "Auto", "Calc selected") + .map { onNodeWithText(it).fetchSemanticsNode().boundsInRoot.height } + + listOf(fieldHeight, checkboxHeight, findFieldHeight) + assertTrue( + controlHeights.max() - controlHeights.min() <= 1f, + "filter control heights differ: $controlHeights", + ) + + val bearingWidths = Bearing.entries + .map { onNodeWithText(it.name).fetchSemanticsNode().boundsInRoot.width } + assertTrue( + bearingWidths.max() - bearingWidths.min() <= 1f, + "bearing widths differ: $bearingWidths", + ) + val expectedWidth = with(density) { Density.DIRECTION_BUTTON_WIDTH.toPx() } + assertTrue( + bearingWidths.all { abs(it - expectedWidth) <= 1f }, + "bearing buttons are not $expectedWidth px wide: $bearingWidths", + ) + } + + @Test + fun functionalFilterGroupsRemainDistinctAndInsideANarrowBar() = runComposeUiTest { + val width = 300.dp + setContent { + Kst4ContestTheme(darkMode = true, baseFontSizeSp = 12f) { + Box(Modifier.width(width)) { + StationFilterBar( + filters = StationFilterState(), + activeBands = EnumSet.of(Band.B_144), + reachability = ReachabilityState(EnumSet.of(Band.B_144)), + onChanged = {}, + ) + } + } + } + + val limit = with(density) { width.toPx() } + listOf( + FILTER_DISTANCE_GROUP_TAG, + FILTER_DIRECTION_GROUP_TAG, + FILTER_STATION_GROUP_TAG, + FILTER_WORKED_GROUP_TAG, + FILTER_PROPAGATION_GROUP_TAG, + ).forEach { tag -> + val bounds = onNodeWithTag(tag).assertIsDisplayed().fetchSemanticsNode().boundsInRoot + assertTrue(bounds.left >= 0f && bounds.right <= limit + 0.5f, "$tag crosses the filter bar") + } + } } diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/StationListScreenTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationListScreenTest.kt index 0c65cc52..08c2142a 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/StationListScreenTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationListScreenTest.kt @@ -2,17 +2,23 @@ package kst4contest.view.compose import androidx.compose.ui.test.ComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.MouseButton +import androidx.compose.ui.test.click import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performMouseInput import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.IntOffset import kst4contest.model.AirPlane import kst4contest.model.AirPlaneReflectionInfo import kst4contest.model.Band import kst4contest.model.ChatCategory import kst4contest.model.ChatMember +import kst4contest.model.RecentChatJoin import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import java.util.EnumSet @@ -41,6 +47,14 @@ import java.util.EnumSet @OptIn(ExperimentalTestApi::class) class StationListScreenTest { + @Test + fun anEmptyTableKeepsItsStructureAndExplainsTheEmptyBody() = runComposeUiTest { + show(stationList()) + + onNodeWithText("No entries").assertExists() + onNodeWithText("QRB").assertExists() + } + private fun stationList(): DataTableState = DataTableState( StationColumns.all( tropoOf = { "" }, @@ -77,10 +91,15 @@ class StationListScreenTest { private fun ComposeUiTest.show( state: DataTableState, onRowClick: (ChatMember) -> Unit = {}, + onRowContextMenu: ((ChatMember, IntOffset) -> Unit)? = null, ) { setContent { Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { - DataTableView(state = state, onRowClick = onRowClick) + DataTableView( + state = state, + onRowClick = onRowClick, + onRowContextMenu = onRowContextMenu, + ) } } waitForIdle() @@ -99,7 +118,7 @@ class StationListScreenTest { val inSecond = station("DL1ABC", category = 3) val clicked = mutableListOf() - show(state) { clicked += it } + show(state, onRowClick = { clicked += it }) runOnIdle { state.replaceRows(listOf(inMain, inSecond)) } waitForIdle() @@ -140,6 +159,7 @@ class StationListScreenTest { onNodeWithText("QRB").performClick() waitForIdle() + assertEquals("qrb", state.sortedColumnId, "clicking the header did not select its sort") onNodeWithText("DL2BBB").assertDoesNotExist() assertEquals(listOf("DL1AAA", "DL3CCC"), drawnOrder("DL3CCC", "DL1AAA")) } @@ -162,6 +182,7 @@ class StationListScreenTest { onNodeWithText("AP [minutes / pot%]").performClick() waitForIdle() + assertEquals("airscout", state.sortedColumnId, "clicking the header did not select its sort") assertEquals( listOf("DL1THREE", "DL1THIRTEEN", "DL1NONE"), drawnOrder("DL1THIRTEEN", "DL1NONE", "DL1THREE"), @@ -196,6 +217,52 @@ class StationListScreenTest { assertEquals(null, state.selectedRow) } + @Test + fun aSecondaryClickReportsTheExactStationAndWindowPosition() = runComposeUiTest { + val state = stationList() + val member = station("DL1ABC", category = 3) + state.replaceRows(listOf(member)) + val requests = mutableListOf>() + + show(state, onRowContextMenu = { target, position -> requests += target to position }) + onNodeWithText("DL1ABC").performMouseInput { + click(button = MouseButton.Secondary) + } + waitForIdle() + + assertEquals(1, requests.size) + assertSame(member, requests.single().first) + assertTrue(requests.single().second.x >= 0 && requests.single().second.y >= 0) + } + + @Test + fun anExplicitSelectionScrollsUsingTheCurrentSortedPosition() = runComposeUiTest { + val selection = StationSelectionState() + val state = DataTableState( + StationColumns.all( + tropoOf = { "" }, + priorityScoreOf = { 0.0 }, + formatBand = { _, _, _ -> "" }, + activeBands = EnumSet.noneOf(Band::class.java), + ), + RowKeys.byValue(StationColumns::rowKey), + initialSortColumnId = "callsign", + selectionModel = selection, + keepSelectionVisible = true, + ) + val stations = (1..60).map { number -> station("DL${number.toString().padStart(2, '0')}TEST") } + state.replaceRows(stations.reversed()) + + show(state) + onNodeWithText("DL60TEST").assertDoesNotExist() + + runOnIdle { selection.select(stations.last(), StationSelectionSource.PUBLIC_MESSAGE) } + waitForIdle() + + onNodeWithText("DL60TEST").assertExists() + assertSame(stations.last(), state.selectedRow) + } + /** * A station's fields change in place and the application calls forceRedraw: the list * filters again, so a station the filter now excludes leaves the screen (#21). @@ -221,4 +288,33 @@ class StationListScreenTest { onNodeWithText("DL1MOVE").assertDoesNotExist() onNodeWithText("DL2STAY").assertExists() } + + @Test + fun onlyTheCallsignCellOfARecentlyJoinedStationIsEmphasized() = runComposeUiTest { + val state = stationList() + val joined = station("HB9SJV", 2) + val existing = station("DL1OLD", 2) + val recent = NewJoinedState(clock = { 5_000L }, onSelect = {}).apply { + updateActiveMembers(listOf(joined, existing)) + updateEvents(listOf(RecentChatJoin("HB9SJV", 2, 1_000L))) + } + val emphasized = mutableSetOf>() + state.replaceRows(listOf(joined, existing)) + + setContent { + Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { + DataTableView( + state = state, + emphasizeCell = { member, column -> + val emphasize = column.id == "callsign" && recent.isRecent(member) + if (emphasize) emphasized += member.callSign to column.id + emphasize + }, + ) + } + } + waitForIdle() + + assertEquals(setOf("HB9SJV" to "callsign"), emphasized) + } } diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/StationSelectionStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationSelectionStateTest.kt new file mode 100644 index 00000000..138d3a2b --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationSelectionStateTest.kt @@ -0,0 +1,124 @@ +package kst4contest.view.compose + +import kst4contest.model.ChatCategory +import kst4contest.model.ChatMember +import kst4contest.model.ChatMessage +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class StationSelectionStateTest { + + @Test + fun `identity includes the full callsign and category`() { + val main = station("DN9APW-2", 2) + val second = station("DN9APW-2", 3) + + assertNotEquals(StationIdentity.of(main), StationIdentity.of(second)) + assertEquals(2, StationIdentity.of(main)?.categoryNumber) + assertEquals("DN9APW-2", StationIdentity.of(main)?.callSign) + } + + @Test + fun `selecting the same station again creates another scroll request`() { + val state = StationSelectionState() + val station = station("DL1ABC", 2) + + state.select(station, StationSelectionSource.PUBLIC_MESSAGE) + val first = state.scrollRequestRevision + state.select(station, StationSelectionSource.MAP) + + assertSame(station, state.selected) + assertTrue(state.scrollRequestRevision > first) + assertEquals(StationSelectionSource.MAP, state.lastSource) + } + + @Test + fun `direct station table selections do not request scrolling`() { + val state = StationSelectionState() + + state.select(station("DL1ABC", 2), StationSelectionSource.STATION_TABLE) + + assertEquals(0L, state.scrollRequestRevision) + } + + @Test + fun `station table context selection does not request scrolling`() { + val state = StationSelectionState() + + state.select(station("DL1ABC", 2), StationSelectionSource.STATION_CONTEXT_MENU) + + assertEquals(0L, state.scrollRequestRevision) + } + + @Test + fun `map context selection requests scrolling again for the same station`() { + val state = StationSelectionState() + val station = station("DL1ABC", 2) + + state.select(station, StationSelectionSource.MAP_CONTEXT_MENU) + val first = state.scrollRequestRevision + state.select(station, StationSelectionSource.MAP_CONTEXT_MENU) + + assertTrue(state.scrollRequestRevision > first) + } + + @Test + fun `reconciling a refreshed instance keeps the selection without requesting a scroll`() { + val state = StationSelectionState() + state.select(station("DL1ABC", 2), StationSelectionSource.STATION_TABLE) + val revision = state.scrollRequestRevision + val refreshed = station("DL1ABC", 2) + + state.reconcile(refreshed) + + assertSame(refreshed, state.selected) + assertEquals(revision, state.scrollRequestRevision) + } + + @Test + fun `incoming directed message targets its sender`() { + val message = directedMessage(sender = "DL1ABC", receiver = "OWN", category = 2) + + assertEquals( + StationIdentity("DL1ABC", 2), + DirectedMessageStationTarget.forMessage(message, "OWN"), + ) + } + + @Test + fun `own directed message targets its receiver`() { + val message = directedMessage(sender = "own", receiver = "DL2XYZ", category = 3) + + assertEquals( + StationIdentity("DL2XYZ", 3), + DirectedMessageStationTarget.forMessage(message, "OWN"), + ) + } + + @Test + fun `historical own message can obtain its receiver from the payload`() { + val message = directedMessage(sender = "OWN", receiver = null, category = 2).apply { + messageText = "(DL3OLD) test" + } + + assertEquals( + StationIdentity("DL3OLD", 2), + DirectedMessageStationTarget.forMessage(message, "OWN"), + ) + } + + private fun station(callSign: String, category: Int): ChatMember = ChatMember().apply { + this.callSign = callSign + chatCategory = ChatCategory(category) + } + + private fun directedMessage(sender: String, receiver: String?, category: Int) = ChatMessage().apply { + this.sender = station(sender, category) + this.receiver = receiver?.let { station(it, category) } + chatCategory = ChatCategory(category) + messageText = "test" + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/StationWorkspaceVisualRenderingTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationWorkspaceVisualRenderingTest.kt new file mode 100644 index 00000000..b4a3547e --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationWorkspaceVisualRenderingTest.kt @@ -0,0 +1,157 @@ +package kst4contest.view.compose + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.dp +import kst4contest.model.Band +import kst4contest.model.ChatCategory +import kst4contest.model.ChatMember +import kst4contest.model.ChatMessage +import kst4contest.model.RecentChatJoin +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.nio.file.Files +import java.nio.file.Path +import java.util.EnumSet +import java.util.function.Consumer + +/** Produces review images for the station filters, join bar and selected-station splitter. */ +class StationWorkspaceVisualRenderingTest { + + @Test + fun rendersDayAndEveningAt100And125Percent() { + val reportDirectory = Path.of("build", "reports", "station-workspace-ui") + Files.createDirectories(reportDirectory) + + listOf( + Scenario("wide", 700, 768), + Scenario("narrow", 360, 768), + Scenario("short", 570, 480), + ).forEach { scenario -> + listOf(false, true).forEach { darkMode -> + listOf(1f, 1.25f).forEach { scale -> + val bytes = render(scenario, darkMode, scale) + val palette = if (darkMode) "evening" else "day" + val scaling = if (scale == 1f) "100" else "125" + val name = "${scenario.name}-$palette-$scaling.png" + Files.write(reportDirectory.resolve(name), bytes) + assertTrue(bytes.size > 10_000, "$name did not render") + } + } + } + } + + private fun render(scenario: Scenario, darkMode: Boolean, scale: Float): ByteArray { + val members = (1..7).map { index -> member("DL${index}ABC", if (index == 7) 3 else 2) } + val selectedMember = members.last().apply { + qrb = 438.2 + setQTFdirection(76.0) + isQrv432 = false + } + val newJoined = NewJoinedState(clock = { 20_000L }, onSelect = {}).apply { + updateActiveMembers(members) + updateEvents(members.mapIndexed { index, member -> + RecentChatJoin(member.callSign, member.chatCategory.categoryNumber, 10_000L + index) + }) + } + val stations = DataTableState( + StationColumns.all( + tropoOf = { "12 dB" }, + priorityScoreOf = { 500.0 }, + formatBand = { _, _, _ -> "" }, + activeBands = EnumSet.of(Band.B_144, Band.B_432), + ), + RowKeys.byValue(StationColumns::rowKey), + ).apply { replaceRows(members) } + val priority = TopPriorityState( + topStations = { listOf(members[4] to 767.0, members[3] to 740.0) }, + onSelect = {}, + ).apply { refresh() } + val selected = SelectedStationState( + propagateNotQrv = {}, + createSked = { _, _, _, _ -> true }, + detectBands = { "Bands: 144/432 MHz" }, + ).apply { select(selectedMember) } + val selectedMessages = DataTableState( + SelectedStationMessageColumns.all(), + RowKeys.byReference(), + ) + + val scene = ImageComposeScene( + width = 1366, + height = scenario.height, + density = Density(scale), + ) { + Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + Box(Modifier.fillMaxSize()) { + Column( + Modifier + .width(scenario.width.dp) + .fillMaxHeight() + .align(Alignment.TopEnd) + .padding(4.dp), + ) { + StationFilterBar( + filters = StationFilterState(), + activeBands = EnumSet.of(Band.B_144, Band.B_432), + reachability = ReachabilityState(EnumSet.of(Band.B_144, Band.B_432)), + onChanged = {}, + ) + DataTableView( + state = stations, + modifier = Modifier.weight(0.45f), + emphasizeCell = { member, column -> + column.id == "callsign" && newJoined.isRecent(member) + }, + ) + TopPriorityBar( + state = priority, + newJoined = newJoined, + onShowMore = {}, + ) + SelectedStationPanel( + state = selected, + messages = selectedMessages, + activeBands = EnumSet.of(Band.B_144, Band.B_432), + contentSplitter = SplitterState(2, doubleArrayOf(0.48)) {}, + openInBrowser = Consumer {}, + onShowOnMap = {}, + onShowPathInAirScout = {}, + modifier = Modifier.weight(0.55f), + ) + } + } + } + } + } + + return try { + scene.render().encodeToData()!!.bytes + } finally { + scene.close() + } + } + + private fun member(callSign: String, category: Int) = ChatMember().apply { + this.callSign = callSign + chatCategory = ChatCategory(category) + qra = "JO31AA" + name = "Contest station" + } + + private data class Scenario(val name: String, val width: Int, val height: Int) +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/TimelineStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/TimelineStateTest.kt index 7230a171..eb014a82 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/TimelineStateTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/TimelineStateTest.kt @@ -124,7 +124,9 @@ class TimelineStateTest { @Test fun `the lanes stack downwards in even steps`() { assertEquals(2f, TimelineGeometry.laneY(0)) - assertEquals(22f, TimelineGeometry.laneY(1)) + assertEquals(18f, TimelineGeometry.laneY(1)) + assertEquals(34f, TimelineGeometry.laneY(2)) + assertEquals(50f, TimelineGeometry.laneY(3)) } /** @@ -151,14 +153,45 @@ class TimelineStateTest { ) } - /** A third lane would land on the axis, so it shares the last one instead. */ + /** Unknown producer values stay visible in the nearest defined lane. */ @Test 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(3), TimelineGeometry.laneY(7)) assertEquals(TimelineGeometry.laneY(0), TimelineGeometry.laneY(-1)) } + @Test + fun `lane definitions expose the producer semantics in display order`() { + assertEquals( + listOf( + TimelineLane.IN_BEAM_1, + TimelineLane.IN_BEAM_2, + TimelineLane.PRIORITY_1, + TimelineLane.PRIORITY_2, + ), + TimelineLane.entries, + ) + assertEquals(TimelineLane.PRIORITY_1, TimelineLane.forIndex(2)) + assertEquals(TimelineLane.PRIORITY_2, TimelineLane.forIndex(99)) + } + + @Test + fun `a wide timeline gets five minute ticks over the full preview`() { + val ticks = TimelineGeometry.minuteTicks(axisWidth = 600f, minimumLabelSpacing = 30f) + + assertEquals(listOf(0, 5, 10, 15, 20, 25, 30), ticks.map { it.minutes }) + assertEquals(0f, ticks.first().x, 0.01f) + assertEquals(600f, ticks.last().x, 0.01f) + } + + @Test + fun `tick interval grows when labels would overlap`() { + val ticks = TimelineGeometry.minuteTicks(axisWidth = 100f, minimumLabelSpacing = 30f) + + assertEquals(listOf(0, 10, 20, 30), ticks.map { it.minutes }) + assertTrue(ticks.zipWithNext().all { (left, right) -> right.x - left.x >= 30f }) + } + // ---- the labels -------------------------------------------------------- @Test diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/TimelineViewScreenTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/TimelineViewScreenTest.kt new file mode 100644 index 00000000..d5eb67a5 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/TimelineViewScreenTest.kt @@ -0,0 +1,34 @@ +package kst4contest.view.compose + +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.Modifier +import androidx.compose.foundation.layout.width +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.dp +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalTestApi::class) +class TimelineViewScreenTest { + + @Test + fun `timeline shows all producer lanes and relative minute ticks`() = runComposeUiTest { + setContent { + MaterialTheme { + TimelineView( + state = TimelineState(), + onCandidateClicked = {}, + modifier = Modifier.width(700.dp), + ) + } + } + + listOf("In beam 1", "In beam 2", "Priority 1", "Priority 2", "Skeds").forEach { + onNodeWithText(it).assertExists() + } + listOf("0 min", "+5 min", "+10 min", "+15 min", "+20 min", "+25 min", "+30 min").forEach { + onNodeWithText(it).assertExists() + } + } +} 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 index 6f5932b4..544d8fc7 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/ComposeStationMapDrawingTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/ComposeStationMapDrawingTest.kt @@ -5,6 +5,7 @@ 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.MapViewport import kst4contest.view.map.TileFetcher import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue @@ -15,6 +16,7 @@ import java.io.IOException import java.nio.file.Files import javax.imageio.ImageIO import kotlin.math.abs +import kotlin.math.roundToInt /** * What the map actually puts on the canvas. @@ -44,6 +46,8 @@ class ComposeStationMapDrawingTest { private val darkMapBackground = 0x23282D + private val workedGridBorder = 0x000000 + /* * An empty cache and a source that always fails, so that "tiles never arrive" holds by * construction. The default fetcher read the user's disk cache and the real tile @@ -64,6 +68,8 @@ class ComposeStationMapDrawingTest { zoom: Float, markers: List = listOf(station("DN9APW", "JO50JP", 51.0, 10.0)), ownLocator6: String = "", + workedGrossFields: Set = emptySet(), + darkMode: Boolean = true, ): BufferedImage { val scene = ImageComposeScene(canvasWidth, canvasHeight, Density(1f)) { ComposeStationMap( @@ -77,7 +83,8 @@ class ComposeStationMapDrawingTest { antennaBeamWidthDeg = 0.0, maxQrbKm = 0.0, onMarkerClicked = null, - darkMode = true, + workedGrossFields = workedGrossFields, + darkMode = darkMode, modifier = Modifier.fillMaxSize(), ) } @@ -229,6 +236,49 @@ class ComposeStationMapDrawingTest { } } + @Test + fun `worked gross fields use a visible black border in both themes`() { + val dark = render(6f, markers = emptyList(), workedGrossFields = setOf("JO51")) + val light = render(6f, markers = emptyList(), workedGrossFields = setOf("JO51"), darkMode = false) + + assertTrue(countColour(dark, workedGridBorder) > 0) { + "the Evening map has no black Worked-grid border" + } + assertTrue(countColour(light, workedGridBorder) > 0) { + "the Day map has no black Worked-grid border" + } + } + + @Test + fun `worked gross field interior is covered by thirty percent black`() { + val viewport = MapViewport(10.0, 51.0, 6.0, canvasWidth.toDouble(), canvasHeight.toDouble()) + val cell = visibleWorkedGrossFieldCells(viewport, setOf("JO51")).single() + val x = viewport.screenX(cell.westLon() + (cell.eastLon() - cell.westLon()) * 0.25).toInt() + val y = viewport.screenY(cell.northLat() + (cell.southLat() - cell.northLat()) * 0.25).toInt() + + for (darkMode in listOf(false, true)) { + val withoutWorked = render(6f, markers = emptyList(), darkMode = darkMode) + val withWorked = render( + 6f, + markers = emptyList(), + workedGrossFields = setOf("JO51"), + darkMode = darkMode, + ) + val before = withoutWorked.getRGB(x, y) + val after = withWorked.getRGB(x, y) + + for (shift in listOf(16, 8, 0)) { + val beforeChannel = (before shr shift) and 0xFF + val afterChannel = (after shr shift) and 0xFF + val expected = (beforeChannel * 0.70).roundToInt() + assertTrue(abs(afterChannel - expected) <= 2) { + "Worked fill in darkMode=$darkMode changed channel $shift from " + + "$beforeChannel to $afterChannel; expected $expected for 30% black" + } + } + } + } + /** * Every grid line has to be drawn once. * diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/StationMapWindowScreenTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/StationMapWindowScreenTest.kt index 083ecd8c..a20a37de 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/StationMapWindowScreenTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/StationMapWindowScreenTest.kt @@ -1,15 +1,18 @@ -package kst4contest.view.compose.map +package kst4contest.view.compose.map import androidx.compose.ui.test.ComposeUiTest import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.assertIsOff import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.click import androidx.compose.ui.test.isToggleable import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performMouseInput +import androidx.compose.ui.test.MouseButton import androidx.compose.ui.test.runComposeUiTest import androidx.compose.ui.text.input.TextFieldValue import kst4contest.locatorUtils.Location @@ -32,6 +35,10 @@ import kst4contest.view.compose.SelectedStationState import kst4contest.view.compose.SplitterState import kst4contest.view.compose.StationColumns import kst4contest.view.compose.StationFilterState +import kst4contest.view.compose.StationSelectionSource +import kst4contest.view.compose.StationSelectionState +import kst4contest.view.compose.STATION_CONTEXT_MENU_TAG +import kst4contest.view.compose.STATION_CONTEXT_TARGET_CHOOSER_TAG import kst4contest.view.compose.ThreadStatusButtons import kst4contest.view.compose.TimelineState import kst4contest.view.compose.TopPriorityState @@ -41,6 +48,7 @@ import kst4contest.view.map.PathAnalysisResult import kst4contest.view.map.TileFetcher import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -136,6 +144,7 @@ class StationMapWindowScreenTest { } private fun windowState(prefs: ChatPreferences): MainWindowState { + val stationSelection = StationSelectionState() val stations = DataTableState( StationColumns.all( tropoOf = { "" }, @@ -143,6 +152,8 @@ class StationMapWindowScreenTest { formatBand = { _, _, _ -> "" }, ), RowKeys.byValue(StationColumns::rowKey), + selectionModel = stationSelection, + keepSelectionVisible = true, ) val menu = MainMenuState() return MainWindowState( @@ -158,6 +169,7 @@ class StationMapWindowScreenTest { selectedStation = SelectedStationState( propagateNotQrv = {}, createSked = { _, _, _, _ -> false }, + stationSelection = stationSelection, ), topPriority = TopPriorityState(topStations = { emptyList() }, onSelect = {}), timeline = TimelineState(), @@ -185,6 +197,7 @@ class StationMapWindowScreenTest { mapState: MapState, pathAnalysis: MapPathAnalysis? = null, onPathAnalysisAsked: () -> Unit = {}, + onAlwaysOnTopChanged: (Boolean) -> Unit = {}, ) { state.stations.replaceRows(members) setContent { @@ -195,6 +208,7 @@ class StationMapWindowScreenTest { chatMembers = { members }, mapState = mapState, pathAnalysis = { onPathAnalysisAsked(); pathAnalysis }, + onAlwaysOnTopChanged = onAlwaysOnTopChanged, ) } } @@ -217,7 +231,7 @@ class StationMapWindowScreenTest { val dl1abc = station("DL1ABC", 2) state.selectedStation.select(dl1abc) val selectorCalls = mutableListOf() - state.stationSelector = { selectorCalls += it } + state.stationSelector = { member, _ -> selectorCalls += member } val mapState = mapLookingAt("JO50JP", zoom = 15f) show(state, listOf(dl1abc), mapState) @@ -266,7 +280,7 @@ class StationMapWindowScreenTest { state.selectedStation.select(inSecond) state.chatInput.value = TextFieldValue("half typed") val selectorCalls = mutableListOf() - state.stationSelector = { selectorCalls += it } + state.stationSelector = { member, _ -> selectorCalls += member } val mapState = mapLookingAt("JO50JP", zoom = 12f) show(state, listOf(inMain, inSecond), mapState) @@ -281,19 +295,78 @@ class StationMapWindowScreenTest { } @Test - fun aMarkerClickWithNothingSelectedTakesTheFirstVariant() = runComposeUiTest { + fun aMarkerClickWithNothingSelectedTakesTheMainCategory() = runComposeUiTest { val state = windowState(prefs()) val inMain = station("DL1ABC", 2) val inSecond = station("DL1ABC", 3) - val selectorCalls = mutableListOf() - state.stationSelector = { selectorCalls += it } + val scrollRevision = state.stations.scrollRequestRevision show(state, listOf(inMain, inSecond), mapLookingAt("JO50JP", zoom = 12f)) onNodeWithTag(StationMapWindow.MAP_CANVAS_TAG).performClick() waitForIdle() - assertSame(inMain, selectorCalls.single()) + assertSame(inMain, state.stations.selectedRow) + assertEquals(StationSelectionSource.MAP, state.stationSelection.lastSource) + assertTrue(state.stations.scrollRequestRevision > scrollRevision) + } + + @Test + fun aMarkerContextClickSelectsItsOnlyTargetAndOpensTheSharedMenu() = runComposeUiTest { + val state = windowState(prefs()) + val member = station("DL1ABC", 2) + val scrollRevision = state.stations.scrollRequestRevision + + show(state, listOf(member), mapLookingAt("JO50JP", zoom = 12f)) + + onNodeWithTag(StationMapWindow.MAP_CANVAS_TAG).performMouseInput { + click(button = MouseButton.Secondary) + } + waitForIdle() + + onNodeWithTag(STATION_CONTEXT_MENU_TAG).assertExists() + assertSame(member, state.stations.selectedRow) + assertEquals(StationSelectionSource.MAP_CONTEXT_MENU, state.stationSelection.lastSource) + assertTrue(state.stations.scrollRequestRevision > scrollRevision) + } + + @Test + fun aMarkerContextClickRequiresCategoryChoiceForAmbiguousTargets() = runComposeUiTest { + val state = windowState(prefs()) + val inMain = station("DL1ABC", 2) + val inSecond = station("DL1ABC", 3) + + show(state, listOf(inMain, inSecond), mapLookingAt("JO50JP", zoom = 12f)) + + onNodeWithTag(StationMapWindow.MAP_CANVAS_TAG).performMouseInput { + click(button = MouseButton.Secondary) + } + waitForIdle() + + onNodeWithTag(STATION_CONTEXT_TARGET_CHOOSER_TAG).assertExists() + assertEquals(null, state.stations.selectedRow) + } + + @Test + fun workedGrossFieldsAreReadOnlyWhileGridColouringIsEnabled() = runComposeUiTest { + val state = windowState(prefs()) + var reads = 0 + state.workedGrossFieldsProvider = { + reads++ + setOf("JO50") + } + + show(state, emptyList(), mapLookingAt("JO50JP", zoom = 6f)) + assertEquals(0, reads) + + runOnIdle { state.stationFilter.gridColouring = true } + waitForIdle() + assertTrue(reads > 0, "enabling Grid color did not request the persistent worked fields") + + val readsWhileEnabled = reads + runOnIdle { state.stationFilter.gridColouring = false } + waitForIdle() + assertEquals(readsWhileEnabled, reads, "disabled Grid color still read worked fields") } /** @@ -377,13 +450,55 @@ class StationMapWindowScreenTest { assertEquals(true, prefs.isGUIstationMapPathAnalysisVisible) // The grouping checkbox sits beside its label; the label itself is not clickable. - onNodeWithText("Group nearby stations").assertExists() - onNode(isToggleable()).assertIsOff().performClick() + onNodeWithText("Grouping").assertExists() + onNodeWithTag(StationMapWindow.GROUPING_TAG).assertIsOff().performClick() waitForIdle() - onNode(isToggleable()).assertIsOn() + onNodeWithTag(StationMapWindow.GROUPING_TAG).assertIsOn() assertEquals(true, prefs.isGUIstationMapClusteringEnabled) } + @Test + fun alwaysOnTopWritesThePreferenceAndAppliesBothStatesImmediately() = runComposeUiTest { + val prefs = prefs() + val state = windowState(prefs) + val applied = mutableListOf() + + show(state, emptyList(), MapState(), onAlwaysOnTopChanged = applied::add) + + onNodeWithText("Always on top").assertExists() + onNodeWithTag(StationMapWindow.ALWAYS_ON_TOP_TAG).assertIsOff().performClick() + waitForIdle() + assertEquals(true, prefs.isGUIstationMapAlwaysOnTop) + assertEquals(listOf(true), applied) + + onNodeWithTag(StationMapWindow.ALWAYS_ON_TOP_TAG).assertIsOn().performClick() + waitForIdle() + assertEquals(false, prefs.isGUIstationMapAlwaysOnTop) + assertEquals(listOf(true, false), applied) + } + + @Test + fun resetFiltersUsesTheSharedActionAndDoesNotResetTheView() = runComposeUiTest { + val state = windowState(prefs()) + state.stationFilter.minQrbKm = 300.0 + state.stationFilter.minQrbEnabled = true + var resets = 0 + state.stationFiltersChanged = { resets++ } + val mapState = mapLookingAt("JO50JP", zoom = 14f) + val beforeLon = mapState.centerLon + val beforeLat = mapState.centerLat + + show(state, emptyList(), mapState) + onNodeWithText("Reset filters").performClick() + waitForIdle() + + assertEquals(false, state.stationFilter.minQrbEnabled) + assertEquals(1, resets) + assertEquals(beforeLon, mapState.centerLon) + assertEquals(beforeLat, mapState.centerLat) + assertEquals(14f, mapState.zoom) + } + /** * The map shows the stations the list shows (#21). The Compose map drew every station * whatever the filter bar said, although its status line reported a filtered view; the @@ -396,7 +511,7 @@ class StationMapWindowScreenTest { val near = station("DL1NEAR", 2, locator = "JO50JP").apply { qrb = 150.0 } val far = station("DL2FAR", 2, locator = "JO62QM").apply { qrb = 600.0 } val selectorCalls = mutableListOf() - state.stationSelector = { selectorCalls += it } + state.stationSelector = { member, _ -> selectorCalls += member } show(state, listOf(near, far), mapLookingAt("JO50JP", zoom = 12f)) onNodeWithText("Showing 2 visible stations").assertExists() diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/map/WorkedGrossFieldOverlayTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/WorkedGrossFieldOverlayTest.kt new file mode 100644 index 00000000..51c4900f --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/map/WorkedGrossFieldOverlayTest.kt @@ -0,0 +1,25 @@ +package kst4contest.view.compose.map + +import kst4contest.view.map.MapViewport +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class WorkedGrossFieldOverlayTest { + + @Test + fun `worked cells are planned from persistent gross fields without station markers`() { + val viewport = MapViewport(10.0, 51.0, 6.0, 900.0, 600.0) + + val cells = visibleWorkedGrossFieldCells(viewport, setOf("JO51", "AA00")) + + assertEquals(listOf("JO51"), cells.map { it.locatorLabel() }) + } + + @Test + fun `disabled grid colour produces no worked overlay cells`() { + val viewport = MapViewport(10.0, 51.0, 6.0, 900.0, 600.0) + + assertTrue(visibleWorkedGrossFieldCells(viewport, emptySet()).isEmpty()) + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/DraftedSettingsTableTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/DraftedSettingsTableTest.kt index 907bda60..d76e18d3 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/DraftedSettingsTableTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/DraftedSettingsTableTest.kt @@ -82,6 +82,9 @@ class DraftedSettingsTableTest { f.station.stn_pathAnalysisDxHighestPointInSquare = v }, flag("station rotator", { it.station.stn_pstRotatorEnabled }) { f, v -> f.station.stn_pstRotatorEnabled = v }, + flag("station QTF filter heading sync", { it.station.stn_useQtfFilterDirectionAsAntennaHeading }) { f, v -> + f.station.stn_useQtfFilterDirectionAsAntennaHeading = v + }, Case("station rotator host", { it.station.stn_pstRotatorHost += "x" }, { it.prefs.getStn_pstRotatorHost() }), Case("station rotator port", { it.station.stn_pstRotatorPort += 1 }, { it.prefs.getStn_pstRotatorPort() }), Case("station QTF", { it.station.stn_qtfDefault += 1.5 }, { it.prefs.getStn_qtfDefault() }), diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/SettingsTabScreens.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/SettingsTabScreens.kt index 13526db2..fe2cbda6 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/SettingsTabScreens.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/SettingsTabScreens.kt @@ -36,9 +36,12 @@ import kst4contest.view.compose.Kst4ContestTheme * control further down can be scrolled to and clicked. */ @OptIn(ExperimentalTestApi::class) -internal fun ComposeUiTest.showTab(content: @Composable () -> Unit) { +internal fun ComposeUiTest.showTab( + darkMode: Boolean = false, + content: @Composable () -> Unit, +) { setContent { - Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { + Kst4ContestTheme(darkMode = darkMode, baseFontSizeSp = 12f) { Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) { content() } diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StationTabScreenTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StationTabScreenTest.kt index 57ea9703..0858d5b9 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StationTabScreenTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StationTabScreenTest.kt @@ -77,7 +77,9 @@ class StationTabScreenTest { showTab { StationTab(s.state) } listOf("Own station", "Chat category", "ON4KST server", "Active bands", "Defaults", - "PSTRotator", "Path analysis defaults").forEach { onNodeWithText(it).assertExists() } + "Manual antenna heading", "PSTRotator", "Path analysis defaults").forEach { + onNodeWithText(it).assertExists() + } fieldBeside("Callsign").assertTextEquals("DN9APW") fieldBeside("Server port").assertTextEquals("23001") @@ -85,6 +87,36 @@ class StationTabScreenTest { checkBeside("10 GHz").assertIsOff() } + @Test + fun qtfFilterHeadingOptionFollowsTheDraftedPrimaryRotatorState() = runComposeUiTest { + val s = Session(preferences()) + showTab { StationTab(s.state) } + + val label = "Use QTF filter direction as antenna heading" + checkBeside(label).assertIsEnabled().assertIsOn() + + toggle("Rotator sync enabled") + + checkBeside(label).assertIsNotEnabled().assertIsOn() + toggle("Rotator sync enabled") + checkBeside(label).assertIsEnabled().assertIsOn() + runOnIdle { s.draft.apply() } + assertFalse(s.prefs.isStn_pstRotatorEnabled()) + assertTrue(s.prefs.isStn_useQtfFilterDirectionAsAntennaHeading()) + } + + @Test + fun manualAntennaHeadingPanelRendersInEveningTheme() = runComposeUiTest { + val s = Session(preferences()) + + showTab(darkMode = true) { StationTab(s.state) } + + onNodeWithText("Manual antenna heading").assertExists() + checkBeside("Use QTF filter direction as antenna heading") + .assertIsEnabled() + .assertIsOn() + } + /* The credentials write through; every other field waits for Apply. */ @Test fun editingTheTextFieldsStagesAllButTheCredentials() = runComposeUiTest { @@ -125,6 +157,18 @@ class StationTabScreenTest { assertEquals(135.5, s.prefs.stn_qtfDefault, 1e-9) } + @Test + fun invalidQtfDefaultsAreNotStaged() = runComposeUiTest { + val s = Session(preferences()) + showTab { StationTab(s.state) } + + listOf("360,1", "-0,1", "NaN", "Infinity").forEach { invalid -> + type("QTF", invalid) + runOnIdle { s.draft.apply() } + assertEquals(135.0, s.prefs.stn_qtfDefault, 1e-9, "accepted $invalid") + } + } + /* * The draft is Compose state, so a staged number comes straight back to its field. The * field must keep the operator's text rather than redraw the number: "1" shown as "1.0" 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 index b24e9a23..09b0058e 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StationTabStateTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StationTabStateTest.kt @@ -12,7 +12,7 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.io.TempDir import java.nio.file.Path -class StationTabStateTest(@TempDir private val tempDir: Path) { +class StationTabStateTest(@param:TempDir private val tempDir: Path) { private inner class Session { val prefs = ChatPreferences(tempDir.resolve("preferences.xml")) @@ -149,6 +149,76 @@ class StationTabStateTest(@TempDir private val tempDir: Path) { assertEquals(23002, s.prefs.getStn_on4kstServersPort()) } + @Test + fun `QTF filter heading synchronization waits in the draft until apply`() { + val s = Session() + + s.state.stn_useQtfFilterDirectionAsAntennaHeading = false + + assertFalse(s.state.stn_useQtfFilterDirectionAsAntennaHeading) + assertTrue(s.prefs.isStn_useQtfFilterDirectionAsAntennaHeading()) + + s.draft.apply() + + assertFalse(s.prefs.isStn_useQtfFilterDirectionAsAntennaHeading()) + } + + @Test + fun `enabling primary rotator preserves QTF filter heading preference in the draft`() { + val s = Session() + + s.state.stn_pstRotatorEnabled = true + + assertTrue(s.state.stn_useQtfFilterDirectionAsAntennaHeading) + assertTrue(s.prefs.isStn_useQtfFilterDirectionAsAntennaHeading(), "an edit took effect before Apply") + + s.draft.apply() + + assertTrue(s.prefs.isStn_pstRotatorEnabled()) + assertTrue(s.prefs.isStn_useQtfFilterDirectionAsAntennaHeading()) + assertFalse(s.prefs.isQtfFilterDirectionAsAntennaHeadingEnabled()) + } + + @Test + fun `disabling primary rotator restores QTF filter heading synchronization`() { + val s = Session() + + s.state.stn_pstRotatorEnabled = true + s.state.stn_pstRotatorEnabled = false + + assertTrue(s.state.stn_useQtfFilterDirectionAsAntennaHeading) + + s.draft.apply() + + assertTrue(s.prefs.isQtfFilterDirectionAsAntennaHeadingEnabled()) + } + + @Test + fun `explicit QTF heading opt out survives primary rotator changes`() { + val s = Session() + s.state.stn_useQtfFilterDirectionAsAntennaHeading = false + s.state.stn_pstRotatorEnabled = true + s.state.stn_pstRotatorEnabled = false + + assertFalse(s.state.stn_useQtfFilterDirectionAsAntennaHeading) + + s.draft.apply() + + assertFalse(s.prefs.isQtfFilterDirectionAsAntennaHeadingEnabled()) + } + + @Test + fun `rotator 2 does not disable QTF filter heading synchronization`() { + val s = Session() + + s.state.stn_useQtfFilterDirectionAsAntennaHeading = true + s.state.stn_pstRotator2Enabled = true + s.draft.apply() + + assertTrue(s.prefs.isStn_useQtfFilterDirectionAsAntennaHeading()) + assertTrue(s.prefs.isQtfFilterDirectionAsAntennaHeadingEnabled()) + } + @Test fun `a discarded station setting leaves the preferences alone`() { val s = Session() diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StylesheetExportTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StylesheetExportTest.kt index 556d8223..26cd3ed4 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StylesheetExportTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/tabs/StylesheetExportTest.kt @@ -93,12 +93,16 @@ class StylesheetExportTest { @Test fun anUnwritablePlaceIsReportedRatherThanThrown() { - val target = File("/proc/kst4contest-cannot-write-here/day.css") + val blockingFile = Files.createTempFile("kst4contest-export-blocked-parent", ".tmp").toFile() + try { + val target = File(blockingFile, "day.css") + val outcome = exportShippedStylesheet(target, darkMode = false) - val outcome = exportShippedStylesheet(target, darkMode = false) - - assertTrue(outcome is StylesheetExportOutcome.Failed, "outcome was $outcome") - assertNotNull((outcome as StylesheetExportOutcome.Failed).reason) + assertTrue(outcome is StylesheetExportOutcome.Failed, "outcome was $outcome") + assertNotNull((outcome as StylesheetExportOutcome.Failed).reason) + } finally { + blockingFile.delete() + } } @Test diff --git a/core/src/main/java/kst4contest/controller/ChatController.java b/core/src/main/java/kst4contest/controller/ChatController.java index f45e1a1c..a428777b 100644 --- a/core/src/main/java/kst4contest/controller/ChatController.java +++ b/core/src/main/java/kst4contest/controller/ChatController.java @@ -1648,6 +1648,7 @@ private SimpleRoster * lst_chatMemberList is this very field. */ private final SimpleRoster chatMemberList = new SimpleRoster<>(); // List of active stations + private final SimpleRoster recentChatJoins = new SimpleRoster<>(); private final SimpleRoster> lst_chatMemberListFilterPredicates = new SimpleRoster<>(); private final SimpleRoster lst_clusterMemberList = new SimpleRoster<>(); @@ -1803,6 +1804,20 @@ private SimpleRoster * user-enter events. */ public void addOrUpdateActiveChatMember(ChatMember member) { + addOrUpdateActiveChatMember(member, System.currentTimeMillis()); + } + + /** + * Adds or replaces an active member and records a join only for a previously absent + * full callsign/category identity. + * + * @param member member received in a live user-enter frame + * @param joinedAtEpochMillis wall-clock time of that frame + */ + public void addOrUpdateActiveChatMember( + ChatMember member, + long joinedAtEpochMillis + ) { String key = buildActiveChatMemberKey(member); if (key == null) { return; @@ -1810,7 +1825,7 @@ private SimpleRoster initializeFrequencyFromStationNameIfUnambiguous(member); - activeChatMembersByCallAndCategory.put(key, member); + ChatMember previous = activeChatMembersByCallAndCategory.put(key, member); runOnFxThread(() -> chatMemberList.mutate(list -> { int existingIndex = findChatMemberIndexInList(list, key); @@ -1820,6 +1835,18 @@ private SimpleRoster list.add(member); } })); + + if (previous == null) { + int categoryNumber = member.getChatCategory().getCategoryNumber(); + recentChatJoins.mutate(joins -> { + boolean alreadyRecorded = joins.stream().anyMatch(join -> + join.matches(member.getCallSign(), categoryNumber)); + if (!alreadyRecorded) { + joins.add(0, new RecentChatJoin( + member.getCallSign(), categoryNumber, joinedAtEpochMillis)); + } + }); + } } /** @@ -1834,6 +1861,9 @@ private SimpleRoster } ChatMember removedMember = activeChatMembersByCallAndCategory.remove(key); + int categoryNumber = member.getChatCategory().getCategoryNumber(); + recentChatJoins.mutate(joins -> joins.removeIf(join -> + join.matches(member.getCallSign(), categoryNumber))); runOnFxThread(() -> chatMemberList.mutate(list -> list.removeIf(currentMember -> key.equals(buildActiveChatMemberKey(currentMember))))); return removedMember != null; @@ -1845,6 +1875,7 @@ private SimpleRoster */ public void clearActiveChatMembers() { activeChatMembersByCallAndCategory.clear(); + recentChatJoins.clear(); runOnFxThread(chatMemberList::clear); } @@ -1867,6 +1898,8 @@ private SimpleRoster } int categoryNumber = category.getCategoryNumber(); + recentChatJoins.mutate(joins -> joins.removeIf(join -> + join.getCategoryNumber() == categoryNumber)); List safeMembers = completeMembers == null ? List.of() : new ArrayList<>(completeMembers); Map workedDataFromDatabase = @@ -1992,8 +2025,8 @@ private SimpleRoster /** * Applies a QSO received from an external logger to all active callsign - * variants. Active member objects back JavaFX views, so their state is changed - * only on the JavaFX Application Thread. The band-upgrade hint is evaluated + * variants. Active member objects back UI views, so their state is changed + * only through the configured UI dispatcher. The band-upgrade hint is evaluated * afterwards and therefore sees the new Worked state. * * @param loggedQso validated external QSO @@ -2199,6 +2232,16 @@ private SimpleRoster target.setQrv10G(sourceMember.isQrv10G()); } + if (dbHandler != null) { + try { + dbHandler.updateNotQRVInfoOnChatMember(sourceMember); + } catch (SQLException exception) { + LOGGER.log(Level.WARNING, + "Could not persist NOT-QRV state for " + rawCall, + exception); + } + } + if (scoreService != null) { scoreService.requestRecompute("NOT-QRV state changed"); } @@ -2743,6 +2786,96 @@ private SimpleRoster return chatMemberList; } + /** + * Applies and persists a validated QSO through the same path for external + * logger packets and explicit UI Worked actions. + * + * @param loggedQso validated QSO + * @return true when the QSO was accepted and its station state was persisted + */ + public boolean recordLoggedQso(ExternalLoggedQso loggedQso) { + return loggedQso != null + && recordLoggedQso(loggedQso, loggedQso.toWorkedChatMember()); + } + + /** + * Applies and persists a QSO while retaining the supplied database/backup row. + * Logger listeners use this overload so later backup serialization observes the + * same normalization as database persistence. + * + * @param loggedQso validated QSO + * @param workedCall mutable row derived from the QSO + * @return true when the QSO was accepted and its station state was persisted + */ + public boolean recordLoggedQso(ExternalLoggedQso loggedQso, ChatMember workedCall) { + if (loggedQso == null || workedCall == null || dbHandler == null) { + return false; + } + + LoggedQsoBand loggedBand = loggedQso.getBand(); + if (loggedBand != null + && loggedBand.getProjectBand() != null + && loggedQso.getLocator() != null) { + registerWorkedGrossField( + loggedBand.getProjectBand(), + loggedQso.getLocator(), + workedCall, + loggedQso.getSource()); + } + + applyExternalLoggedQso(loggedQso); + + try { + boolean isInChat = dbHandler.updateWkdInfoOnChatMember(workedCall); + if (!isInChat) { + workedCall.setName("unknown"); + if (workedCall.getQra() == null || workedCall.getQra().isBlank()) { + workedCall.setQra("unknown"); + } + workedCall.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat()); + dbHandler.storeChatMember(workedCall); + } + return true; + } catch (SQLException exception) { + LOGGER.log(Level.WARNING, + "Could not persist Worked state for " + loggedQso.getCallSign(), + exception); + return false; + } + } + + /** + * Marks one station and band as worked with contactinfo-equivalent semantics. + * Worked contacts are additive; invoking this method for an already worked band + * is intentionally idempotent. + * + * @param member station selected by the UI + * @param band band to mark as worked + * @return true when a valid contact was accepted and persisted + */ + public boolean markStationWorkedOnBand(ChatMember member, Band band) { + if (member == null || band == null) { + return false; + } + + LoggedQsoBand loggedBand = LoggedQsoBand.fromProjectBand(band); + if (loggedBand == null) { + return false; + } + + String callSign = member.getCallSign() != null + ? member.getCallSign() + : member.getCallSignRaw(); + return ExternalLoggedQso.create(callSign, loggedBand, member.getQra(), "MANUAL") + .map(this::recordLoggedQso) + .orElse(false); + } + + /** Live joins only; initial and resynchronized user lists never enter this roster. */ + public ObservableRoster getRecentChatJoins() { + return recentChatJoins; + } + public ObservableRoster getLst_DBBasedWkdCallSignList() { return lst_DBBasedWkdCallSignList; } @@ -2901,7 +3034,7 @@ private SimpleRoster chatPreferences = new ChatPreferences(preferencesRelativeFileName); chatPreferences.readPreferencesFromXmlFile(); - Path historyFile = Path.of(ApplicationFileUtils.getFilePath( + Path historyFile = Path.of(ApplicationFileUtils.getDebugFilePath( ApplicationConstants.APPLICATION_NAME, MessageHistoryRecorder.CURRENT_FILE_NAME)); messageHistoryRecorder = new MessageHistoryRecorder( diff --git a/core/src/main/java/kst4contest/controller/LoggedQsoBand.java b/core/src/main/java/kst4contest/controller/LoggedQsoBand.java index 29cb39d2..d225cc64 100644 --- a/core/src/main/java/kst4contest/controller/LoggedQsoBand.java +++ b/core/src/main/java/kst4contest/controller/LoggedQsoBand.java @@ -92,6 +92,27 @@ public enum LoggedQsoBand { }; } + /** + * Resolves a project band for UI actions that must follow the same Worked + * processing as an external logger contact. + * + * @param band project band + * @return normalized logged-QSO band, or {@code null} for a missing band + */ + public static LoggedQsoBand fromProjectBand(Band band) { + if (band == null) { + return null; + } + + for (LoggedQsoBand loggedBand : values()) { + if (loggedBand.projectBand == band) { + return loggedBand; + } + } + + return null; + } + /** * Returns the common Band value used by worked-grid persistence. * diff --git a/core/src/main/java/kst4contest/controller/MessageBusManagementThread.java b/core/src/main/java/kst4contest/controller/MessageBusManagementThread.java index 6c918cf4..5949ca8a 100644 --- a/core/src/main/java/kst4contest/controller/MessageBusManagementThread.java +++ b/core/src/main/java/kst4contest/controller/MessageBusManagementThread.java @@ -758,8 +758,15 @@ public class MessageBusManagementThread extends Thread { // Built only now: the receiver field has always been read after the sender. InboundChatFrame frame = InboundChatFrame.of(fields); + boolean publicMessage = frame.receiverField().equals("0"); + newMessageArrived.setMessageDirectedToCommunity(publicMessage); + newMessageArrived.setMessageDirectedToMe( + publicMessage + && isPublicMessageExplicitlyAddressedTo( + frame.text(), + prefs.getStn_loginCallSign())); - if (frame.receiverField().equals("0")) { + if (publicMessage) { // message is not directed to anyone, move it to the cq messages. newMessageArrived.setReceiver(createAllReceiver()); @@ -778,6 +785,7 @@ public class MessageBusManagementThread extends Thread { newMessageArrived.getSender().getCallSign(), newMessageArrived.getReceiver().getCallSign(), prefs.getStn_loginCallSign()); + newMessageArrived.setMessageDirectedToMe(route == ChatRoute.TO_ME); DirectionalOpportunity opportunity = null; boolean senderAlreadyInAngleAndRange = false; @@ -1137,7 +1145,7 @@ public class MessageBusManagementThread extends Thread { // System.out.println("MSGBUS: User detected"); - if (!client.getChatPreferences().getStn_loginCallSign().equals(splittedMessageLine[2])) { //own call ignore + if (!client.getChatPreferences().getStn_loginCallSign().equalsIgnoreCase(splittedMessageLine[2])) { //own call ignore ChatMember newMember = new ChatMember(); @@ -1156,7 +1164,8 @@ public class MessageBusManagementThread extends Thread { newMember = this.client.getDbHandler().fetchChatMemberWkdDataForOnlyOneCallsignFromDB(newMember); - this.client.addOrUpdateActiveChatMember(newMember); + this.client.addOrUpdateActiveChatMember( + newMember, millisClock.getAsLong()); // this.client.getReachabilityService().ensureAutoTropoMarginCalculated(newMember); this.client.getDbHandler().storeChatMember(newMember); @@ -1714,6 +1723,33 @@ public class MessageBusManagementThread extends Thread { return ChatRoute.BETWEEN_OTHERS; } + /** + * Recognizes the structured address prefix produced by ON4KST for a public + * {@code /cq CALL ...} message. + * + *

The address must be the first non-whitespace token and occupy the complete + * parenthesized prefix. A callsign occurring later in ordinary prose is therefore + * not treated as an address.

+ */ + static boolean isPublicMessageExplicitlyAddressedTo(String messageText, String ownCall) { + if (messageText == null || ownCall == null || ownCall.isBlank()) { + return false; + } + + String text = messageText.stripLeading(); + if (!text.startsWith("(")) { + return false; + } + + int closingParenthesis = text.indexOf(')'); + if (closingParenthesis <= 1) { + return false; + } + + String addressedCall = text.substring(1, closingParenthesis).trim(); + return addressedCall.equalsIgnoreCase(ownCall.trim()); + } + /** * Returns whether the short tick sound for a new directional opportunity is * played: only with simple sounds enabled and only on the transition into diff --git a/core/src/main/java/kst4contest/controller/ReadUDPByWintestThread.java b/core/src/main/java/kst4contest/controller/ReadUDPByWintestThread.java index 719fe0b4..38f904e8 100644 --- a/core/src/main/java/kst4contest/controller/ReadUDPByWintestThread.java +++ b/core/src/main/java/kst4contest/controller/ReadUDPByWintestThread.java @@ -774,30 +774,7 @@ public class ReadUDPByWintestThread extends Thread { } ChatMember workedCall = loggedQso.toWorkedChatMember(); - if (loggedBand != null - && loggedBand.getProjectBand() != null - && locatorFromLogger != null) { - this.client.registerWorkedGrossField( - loggedBand.getProjectBand(), locatorFromLogger, workedCall, loggedQso.getSource()); - } - - client.applyExternalLoggedQso(loggedQso); - - boolean isInChat = this.client.getDbHandler().updateWkdInfoOnChatMember(workedCall); - // This will update the worked info on a worked chatmember. DBHandler will - // check, if an entry at the db had been modified. If not, then the worked - // station had not been stored. DBHandler will store the information then. - if (!isInChat) { - - workedCall.setName("unknown"); - - if (workedCall.getQra() == null || workedCall.getQra().isBlank()) { - workedCall.setQra("unknown"); - } - - workedCall.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat()); - this.client.getDbHandler().storeChatMember(workedCall); - } + client.recordLoggedQso(loggedQso, workedCall); File logUDPMessageToThisFile = new File(this.client.getChatPreferences() .getLogSynch_storeWorkedCallSignsFileNameUDPMessageBackup()); diff --git a/core/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java b/core/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java index 0b15c825..c167ec46 100644 --- a/core/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java +++ b/core/src/main/java/kst4contest/controller/ReadUDPbyUCXMessageThread.java @@ -3,7 +3,6 @@ package kst4contest.controller; import java.io.*; import java.net.*; import java.nio.charset.StandardCharsets; -import java.sql.SQLException; import java.util.Arrays; import javax.xml.XMLConstants; @@ -366,118 +365,11 @@ public class ReadUDPbyUCXMessageThread extends Thread { // client.getChatPreferences().setBcn_contestScoreSum(Long.parseLong(points)); ChatMember workedCall = loggedQso.toWorkedChatMember(); - Band workedBand = loggedBand == null ? null : loggedBand.getProjectBand(); if (loggedBand == null && !rawBand.isEmpty()) { System.out.println("[ReadUDPFromUCX, warning]: unexpected band value: \"" + rawBand + "\""); } - { - /** - * That means, the station is worked already but maybe at another band. So we - * have to get the worked ChatMember out of the list and to modify the worked - * options. - */ - -// modifyThat = (ChatMember) client.getMap_ucxLogInfoWorkedCalls().get(call); - -// asd //TODO: Check if callsign and callsignraw is similar, then mark first and further via new checklistforchatmembermultiplemethod with array of indize - - client.applyExternalLoggedQso(loggedQso); - - /** - * old mechanic to markup worked stations in the chatmember table - */ -// int indexOfChatMemberInTable = -1; //chatmember not in table -// indexOfChatMemberInTable = client.checkListForChatMemberIndexByCallSign(workedCall); -// -// if (indexOfChatMemberInTable == -1) { -// // do nothing -// } else { -// modifyThat = client.getLst_chatMemberList().get(indexOfChatMemberInTable); -// -// client.getLst_chatMemberList() -// .get(client.checkListForChatMemberIndexByCallSign(modifyThat)).setWorked(true); -// -// if (workedCall.isWorked144()) { -// modifyThat.setWorked144(true); -// client.getLst_chatMemberList() -// .get(client.checkListForChatMemberIndexByCallSign(modifyThat)) -// .setWorked144(true); -// -// } else if (workedCall.isWorked432()) { -// modifyThat.setWorked432(true); -// client.getLst_chatMemberList() -// .get(client.checkListForChatMemberIndexByCallSign(modifyThat)) -// .setWorked432(true); -// -// } else if (workedCall.isWorked1240()) { -// modifyThat.setWorked1240(true); -// client.getLst_chatMemberList() -// .get(client.checkListForChatMemberIndexByCallSign(modifyThat)) -// .setWorked1240(true); -// -// } else if (workedCall.isWorked2300()) { -// modifyThat.setWorked2300(true); -// client.getLst_chatMemberList() -// .get(client.checkListForChatMemberIndexByCallSign(modifyThat)) -// .setWorked2300(true); -// -// } else if (workedCall.isWorked3400()) { -// modifyThat.setWorked3400(true); -// client.getLst_chatMemberList() -// .get(client.checkListForChatMemberIndexByCallSign(modifyThat)) -// .setWorked3400(true); -// -// } else if (workedCall.isWorked5600()) { -// modifyThat.setWorked5600(true); -// client.getLst_chatMemberList() -// .get(client.checkListForChatMemberIndexByCallSign(modifyThat)) -// .setWorked5600(true); -// -// } else if (workedCall.isWorked10G()) { -// modifyThat.setWorked10G(true); -// client.getLst_chatMemberList() -// .get(client.checkListForChatMemberIndexByCallSign(modifyThat)) -// .setWorked10G(true); -// } - /** - * //TODO: following line is a quick fix to making disappear worked chatmembers of the list - * Thats uncomfortable due to this also causes selection changes, - * Better way is to change all worked and qrv values to observables and then trigger the underlying - * list to fire an invalidationevent. Really Todo! - */ -// try{ -// -// GuiUtils.triggerGUIFilteredChatMemberListChange(client); //not clean at all -// } catch (Exception IllegalStateException) { -// //do nothing, as it works... -// } -// } - /** - * end -> old mechanic to markup worked stations in the chatmember table - */ - } - - if (workedBand != null && gridSquare != null) { - this.client.registerWorkedGrossField( - workedBand, gridSquare, workedCall, loggedQso.getSource()); - } - - boolean isInChat = this.client.getDbHandler().updateWkdInfoOnChatMember(workedCall); - // This will update the worked info on a worked chatmember. DBHandler will - // check, if an entry at the db had been modified. If not, then the worked - // station had not been stored. DBHandler will store the information then. - if (!isInChat) { - - workedCall.setName("unknown"); - - if (workedCall.getQra() == null || workedCall.getQra().isBlank()) { - workedCall.setQra("unknown"); - } - - workedCall.setLastActivity(new Utils4KST().time_generateActualTimeInDateFormat()); - this.client.getDbHandler().storeChatMember(workedCall); - } + client.recordLoggedQso(loggedQso, workedCall); appendToUdpBackupFile(this.client.getChatPreferences() @@ -594,18 +486,6 @@ public class ReadUDPbyUCXMessageThread extends Thread { callBackToController.onThreadStatus(ThreadNickName,threadStateMessage); - } catch (SQLException e) { - // TODO Auto-generated catch block - e.printStackTrace(); - threadStateMessage = new ThreadStateMessage(this.ThreadNickName, true, "CRASHED" + udpMsg, true); - threadStateMessage.setCriticalStateFurtherInfo(Arrays.toString(e.getStackTrace())); - - callBackToController.onThreadStatus(ThreadNickName,threadStateMessage); - -// threadStatusMessage = new String[2]; -// threadStatusMessage[0] = "STOPPED"; -// threadStatusMessage[1] = Arrays.toString(e.getStackTrace()); -// callBackToController.onThreadStatus(ThreadNickName,threadStatusMessage); } // System.out.println("[ReadUDPbyUCXTh: ] worked size = " + this.client.getMap_ucxLogInfoWorkedCalls().size()); diff --git a/core/src/main/java/kst4contest/controller/WorkedGrossFieldCache.java b/core/src/main/java/kst4contest/controller/WorkedGrossFieldCache.java index a44dea82..fe058165 100644 --- a/core/src/main/java/kst4contest/controller/WorkedGrossFieldCache.java +++ b/core/src/main/java/kst4contest/controller/WorkedGrossFieldCache.java @@ -146,6 +146,22 @@ public final class WorkedGrossFieldCache { return false; } + /** + * Returns an immutable snapshot of all gross fields worked on at least one + * band. Map rendering uses the snapshot without holding this cache's monitor. + * + * @return immutable any-band gross-field snapshot + */ + public synchronized Set snapshotWorkedGrossFieldsAny() { + Set snapshot = new HashSet<>(); + for (Set workedGrossFields : workedGrossFieldsByBand.values()) { + if (workedGrossFields != null) { + snapshot.addAll(workedGrossFields); + } + } + return Set.copyOf(snapshot); + } + /** * Tries to normalize a locator. Accepts a plain six-character locator or extracts * one from exchange strings such as "001JO41HK". @@ -199,4 +215,4 @@ public final class WorkedGrossFieldCache { String normalized = grossField.trim().toUpperCase(Locale.ROOT); return normalized.matches("[A-R]{2}[0-9]{2}") ? normalized : null; } -} \ No newline at end of file +} diff --git a/core/src/main/java/kst4contest/model/ChatPreferences.java b/core/src/main/java/kst4contest/model/ChatPreferences.java index 42f946d6..3cefbd07 100644 --- a/core/src/main/java/kst4contest/model/ChatPreferences.java +++ b/core/src/main/java/kst4contest/model/ChatPreferences.java @@ -57,11 +57,13 @@ public class ChatPreferences { * only use is a best-effort warning when a file is newer than the application. Release * 1.50 added the two palette-override elements and deliberately left this at 7. A bump * that no reader acts on only invites the next person to bump it again. + * Version 9 marks the manual-QTF-heading opt-out migration. A missing opt-out marker + * enables the feature, and a subsequently written marker preserves the user's decision. *

* Reading must stay backwards compatible: missing/unknown tags should fall back to defaults. */ // private static final int CONFIG_VERSION = 2; - public static final int CONFIG_VERSION = 8; + public static final int CONFIG_VERSION = 9; // Prefer writing tag names that mirror variable names (human readable). Keep legacy tags for compatibility. private static final String TAG_CONFIG_VERSION = "configVersion"; @@ -202,6 +204,7 @@ public class ChatPreferences { int stn_on4kstServersPort = 23001; boolean stn_pstRotatorEnabled = false; + boolean stn_qtfFilterDirectionAsAntennaHeadingOptedOut; String stn_pstRotatorHost = "127.0.0.1"; int stn_pstRotatorPort = 12000; boolean stn_pstRotator2Enabled = false; @@ -259,6 +262,7 @@ public class ChatPreferences { boolean loginToSecondChatEnabled; MutableValue actualQTF = new SimpleValue<>(360.0); // will be updated by user at runtime! MutableValue actualQTF2 = new SimpleValue<>(Double.NaN); // heading of rotator 2 (SO2R), NaN when unknown + private boolean actualQtfInitializedFromPreferences; boolean stn_bandActive50; boolean stn_bandActive70; @@ -425,6 +429,7 @@ public class ChatPreferences { private double[] GUIstationMapStagePositionXY = new double[] { Double.NaN, Double.NaN }; private boolean GUIstationMapPathAnalysisVisible = true; private boolean GUIstationMapClusteringEnabled = true; + private boolean GUIstationMapAlwaysOnTop = false; private final Map tableColumnWidths = new LinkedHashMap<>(); private static final String TAG_TABLE_COLUMN_WIDTH = "tableColumnWidth"; @@ -803,6 +808,14 @@ public class ChatPreferences { this.GUIstationMapClusteringEnabled = GUIstationMapClusteringEnabled; } + public boolean isGUIstationMapAlwaysOnTop() { + return GUIstationMapAlwaysOnTop; + } + + public void setGUIstationMapAlwaysOnTop(boolean GUIstationMapAlwaysOnTop) { + this.GUIstationMapAlwaysOnTop = GUIstationMapAlwaysOnTop; + } + /** * Returns a stored width for one stable table/leaf-column identity. * @@ -1043,6 +1056,20 @@ public class ChatPreferences { this.stn_pstRotatorEnabled = stn_pstRotatorEnabled; } + public boolean isStn_useQtfFilterDirectionAsAntennaHeading() { + return !stn_qtfFilterDirectionAsAntennaHeadingOptedOut; + } + + public void setStn_useQtfFilterDirectionAsAntennaHeading( + boolean stn_useQtfFilterDirectionAsAntennaHeading) { + this.stn_qtfFilterDirectionAsAntennaHeadingOptedOut = + !stn_useQtfFilterDirectionAsAntennaHeading; + } + + public boolean isQtfFilterDirectionAsAntennaHeadingEnabled() { + return !stn_pstRotatorEnabled && !stn_qtfFilterDirectionAsAntennaHeadingOptedOut; + } + public String getStn_pstRotatorHost() { return stn_pstRotatorHost; } @@ -1215,7 +1242,9 @@ public class ChatPreferences { } public void setStn_qtfDefault(double stn_qtfDefault) { - this.stn_qtfDefault = stn_qtfDefault; + if (Double.isFinite(stn_qtfDefault) && stn_qtfDefault >= 0.0 && stn_qtfDefault <= 360.0) { + this.stn_qtfDefault = stn_qtfDefault; + } } public boolean isStn_loginAFKState() { @@ -1733,6 +1762,12 @@ public class ChatPreferences { stn_qtfDefault.setTextContent(this.stn_qtfDefault+""); station.appendChild(stn_qtfDefault); + Element stn_qtfFilterDirectionAsAntennaHeadingOptedOut = + doc.createElement("stn_qtfFilterDirectionAsAntennaHeadingOptedOut"); + stn_qtfFilterDirectionAsAntennaHeadingOptedOut.setTextContent( + Boolean.toString(this.stn_qtfFilterDirectionAsAntennaHeadingOptedOut)); + station.appendChild(stn_qtfFilterDirectionAsAntennaHeadingOptedOut); + Element stn_pathAnalysisOwnAntennaHeightMeters = doc.createElement("stn_pathAnalysisOwnAntennaHeightMeters"); stn_pathAnalysisOwnAntennaHeightMeters.setTextContent(this.stn_pathAnalysisOwnAntennaHeightMeters + ""); station.appendChild(stn_pathAnalysisOwnAntennaHeightMeters); @@ -2395,6 +2430,12 @@ public class ChatPreferences { ); guiOptions.appendChild(GUIstationMapClusteringEnabled); + Element GUIstationMapAlwaysOnTop = doc.createElement("GUIstationMapAlwaysOnTop"); + GUIstationMapAlwaysOnTop.setTextContent( + String.valueOf(this.isGUIstationMapAlwaysOnTop()) + ); + guiOptions.appendChild(GUIstationMapAlwaysOnTop); + appendTableColumnWidths(doc, guiOptions); appendWindowPositions(doc, guiOptions); @@ -2484,6 +2525,8 @@ public class ChatPreferences { getGUIstationMapStagePositionXY()[0] + ";" + getGUIstationMapStagePositionXY()[1]); upsertDirectChildText(document, guiOptions, "GUIstationMapClusteringEnabled", String.valueOf(isGUIstationMapClusteringEnabled())); + upsertDirectChildText(document, guiOptions, "GUIstationMapAlwaysOnTop", + String.valueOf(isGUIstationMapAlwaysOnTop())); } private void appendTableColumnWidths(Document document, Element guiOptions) { @@ -2611,7 +2654,7 @@ public class ChatPreferences { stn_antennaBeamWidthDeg = getDouble(stationEl, stn_antennaBeamWidthDeg, "stn_antennaBeamWidthDeg"); stn_maxQRBDefault = getDouble(stationEl, stn_maxQRBDefault, "stn_maxQRBDefault"); - stn_qtfDefault = getDouble(stationEl, stn_qtfDefault, "stn_qtfDefault"); + setStn_qtfDefault(getDouble(stationEl, stn_qtfDefault, "stn_qtfDefault")); stn_pathAnalysisOwnAntennaHeightMeters = getDouble( stationEl, @@ -2721,6 +2764,16 @@ public class ChatPreferences { stn_bandActive10G = getBoolean(stationEl, stn_bandActive10G, "stn_bandActive10G"); stn_pstRotatorEnabled = getBoolean(stationEl, stn_pstRotatorEnabled, "stn_pstRotatorEnabled"); + /* + * The positive version-8 setting cannot distinguish an explicit false from its old + * default. Absence of the opt-out marker therefore performs the approved migration + * to enabled; once written, the marker preserves every later user decision. + */ + stn_qtfFilterDirectionAsAntennaHeadingOptedOut = getBoolean( + stationEl, + false, + "stn_qtfFilterDirectionAsAntennaHeadingOptedOut" + ); setStn_pstRotatorHost( getText( @@ -3223,6 +3276,7 @@ public class ChatPreferences { * ***********************************************/ this.setGUIstationMapClusteringEnabled(true); + this.setGUIstationMapAlwaysOnTop(false); list = doc.getElementsByTagName("guiOptions"); if (list.getLength() != 0) { @@ -3272,6 +3326,12 @@ public class ChatPreferences { "GUIstationMapClusteringEnabled" )); + this.setGUIstationMapAlwaysOnTop(getBooleanOrDefault( + element, + false, + "GUIstationMapAlwaysOnTop" + )); + // Splitpane divider positions String s1 = getText(element, null, "GUIselectedCallSignSplitPane_dividerposition"); if (s1 != null) { @@ -3387,10 +3447,18 @@ public class ChatPreferences { System.out.println(e.getMessage()); } + initializeActualQtfFromDefaultIfRequired(); return true; } + private void initializeActualQtfFromDefaultIfRequired() { + if (!actualQtfInitializedFromPreferences) { + actualQTF.set(stn_qtfDefault); + actualQtfInitializedFromPreferences = true; + } + } + /** * * @return double array with separated values for each doublevalue, seperated by ";", empty array if none diff --git a/core/src/main/java/kst4contest/model/RecentChatJoin.java b/core/src/main/java/kst4contest/model/RecentChatJoin.java new file mode 100644 index 00000000..b2a05665 --- /dev/null +++ b/core/src/main/java/kst4contest/model/RecentChatJoin.java @@ -0,0 +1,48 @@ +package kst4contest.model; + +import java.util.Locale; +import java.util.Objects; + +/** + * One live ON4KST join event. + * + *

The full callsign and category form the identity. The timestamp belongs to the + * first event while that identity remains active; ordinary user updates must not renew it.

+ */ +public final class RecentChatJoin { + + private final String callSign; + private final int categoryNumber; + private final long joinedAtEpochMillis; + + public RecentChatJoin( + String callSign, + int categoryNumber, + long joinedAtEpochMillis + ) { + this.callSign = Objects.requireNonNull(callSign, "callSign") + .trim() + .toUpperCase(Locale.ROOT); + this.categoryNumber = categoryNumber; + this.joinedAtEpochMillis = joinedAtEpochMillis; + } + + public String getCallSign() { + return callSign; + } + + public int getCategoryNumber() { + return categoryNumber; + } + + public long getJoinedAtEpochMillis() { + return joinedAtEpochMillis; + } + + /** Returns whether the event belongs to the same complete chat identity. */ + public boolean matches(String otherCallSign, int otherCategoryNumber) { + return otherCallSign != null + && categoryNumber == otherCategoryNumber + && callSign.equalsIgnoreCase(otherCallSign.trim()); + } +} diff --git a/core/src/main/java/kst4contest/utils/ApplicationFileLogging.java b/core/src/main/java/kst4contest/utils/ApplicationFileLogging.java index b49e0f4b..b1c83c20 100644 --- a/core/src/main/java/kst4contest/utils/ApplicationFileLogging.java +++ b/core/src/main/java/kst4contest/utils/ApplicationFileLogging.java @@ -1,15 +1,19 @@ package kst4contest.utils; import java.io.IOException; +import java.io.OutputStream; import java.io.PrintWriter; import java.io.StringWriter; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; import java.time.Instant; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.logging.FileHandler; +import java.util.logging.ErrorManager; import java.util.logging.Formatter; import java.util.logging.Handler; import java.util.logging.Level; @@ -20,6 +24,10 @@ import java.util.logging.Logger; public final class ApplicationFileLogging implements AutoCloseable { /** Logger namespace owned by the application. */ private static final String APP_LOGGER_NAME = "kst4contest"; + /** Maximum size of the active error log before it is archived. */ + private static final long DEFAULT_SIZE_LIMIT_BYTES = 20L * 1024L * 1024L; + /** Active error log plus four archived generations. */ + private static final int DEFAULT_FILE_COUNT = 5; /** Runtime switch for additional application diagnostics. */ private final AtomicBoolean debugEnabled = new AtomicBoolean(); @@ -44,6 +52,28 @@ public final class ApplicationFileLogging implements AutoCloseable { final boolean debugEnabled ) throws IOException { + this(logFile, debugEnabled, + DEFAULT_SIZE_LIMIT_BYTES, DEFAULT_FILE_COUNT); + } + + /** Test seam for exercising rotation without writing multi-megabyte files. */ + ApplicationFileLogging( + final Path logFile, + final boolean debugEnabled, + final long sizeLimitBytes, + final int fileCount + ) throws IOException { + this(logFile, debugEnabled, sizeLimitBytes, fileCount, ignored -> { }); + } + + /** Test seam for a transient file-system error after the active stream closes. */ + ApplicationFileLogging( + final Path logFile, + final boolean debugEnabled, + final long sizeLimitBytes, + final int fileCount, + final RotationFaultInjector rotationFaultInjector + ) throws IOException { final Path parent = logFile.toAbsolutePath().getParent(); if (parent != null) { Files.createDirectories(parent); @@ -51,15 +81,175 @@ public final class ApplicationFileLogging implements AutoCloseable { this.debugEnabled.set(debugEnabled); previousAppLevel = appLogger.getLevel(); - appLogger.setLevel(Level.FINE); - final FileHandler fileHandler = - new FileHandler(logFile.toString(), true); + final Handler fileHandler = new BoundedRotatingFileHandler( + logFile, sizeLimitBytes, fileCount, rotationFaultInjector); fileHandler.setLevel(Level.ALL); fileHandler.setFilter(this::isLoggable); fileHandler.setFormatter(new UtcLogFormatter()); + + try { + appLogger.setLevel(Level.FINE); + rootLogger.addHandler(fileHandler); + } catch (RuntimeException exception) { + fileHandler.close(); + try { + appLogger.setLevel(previousAppLevel); + } catch (RuntimeException restoreFailure) { + exception.addSuppressed(restoreFailure); + } + throw exception; + } handler = fileHandler; - rootLogger.addHandler(handler); + } + + /** Injects a test-only failure at the point where archive operations begin. */ + @FunctionalInterface + interface RotationFaultInjector { + void beforeArchive(Path logFile) throws IOException; + } + + /** Keeps the active canonical file name while retaining bounded generations. */ + private static final class BoundedRotatingFileHandler extends Handler { + private final Path logFile; + private final long sizeLimitBytes; + private final int fileCount; + private final RotationFaultInjector rotationFaultInjector; + private OutputStream output; + private long currentSize; + + private BoundedRotatingFileHandler( + final Path logFile, + final long sizeLimitBytes, + final int fileCount, + final RotationFaultInjector rotationFaultInjector + ) throws IOException { + if (sizeLimitBytes <= 0L) { + throw new IllegalArgumentException("sizeLimitBytes must be positive"); + } + if (fileCount <= 0) { + throw new IllegalArgumentException("fileCount must be positive"); + } + this.logFile = logFile; + this.sizeLimitBytes = sizeLimitBytes; + this.fileCount = fileCount; + this.rotationFaultInjector = rotationFaultInjector; + open(true); + } + + @Override + public synchronized void publish(final LogRecord record) { + if (!isLoggable(record)) { + return; + } + + final byte[] encoded = getFormatter().format(record) + .getBytes(StandardCharsets.UTF_8); + try { + if (output == null) { + open(true); + } + if (currentSize > 0L + && currentSize + encoded.length > sizeLimitBytes) { + try { + rotate(); + } catch (IOException | RuntimeException exception) { + reportError("Could not rotate " + logFile, + exception, ErrorManager.WRITE_FAILURE); + if (output == null) { + return; + } + } + } + output.write(encoded); + output.flush(); + currentSize += encoded.length; + } catch (IOException | RuntimeException exception) { + reportError("Could not write or rotate " + logFile, + exception, ErrorManager.WRITE_FAILURE); + } + } + + @Override + public synchronized void flush() { + if (output == null) { + return; + } + try { + output.flush(); + } catch (IOException exception) { + reportError("Could not flush " + logFile, + exception, ErrorManager.FLUSH_FAILURE); + } + } + + @Override + public synchronized void close() { + closeOutput(); + } + + private void rotate() throws IOException { + closeOutput(); + try { + rotationFaultInjector.beforeArchive(logFile); + if (fileCount > 1) { + Files.deleteIfExists(generation(fileCount - 1)); + for (int generation = fileCount - 2; + generation >= 1; + generation--) { + Path source = generation(generation); + if (Files.exists(source)) { + Files.move(source, generation(generation + 1), + StandardCopyOption.REPLACE_EXISTING); + } + } + if (Files.exists(logFile)) { + Files.move(logFile, generation(1), + StandardCopyOption.REPLACE_EXISTING); + } + } + open(false); + } catch (IOException | RuntimeException exception) { + try { + open(true); + } catch (IOException | RuntimeException recoveryFailure) { + exception.addSuppressed(recoveryFailure); + } + throw exception; + } + } + + private Path generation(final int generation) { + return logFile.resolveSibling( + logFile.getFileName() + "." + generation); + } + + private void open(final boolean append) throws IOException { + output = append + ? Files.newOutputStream(logFile, + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + StandardOpenOption.APPEND) + : Files.newOutputStream(logFile, + StandardOpenOption.CREATE, + StandardOpenOption.WRITE, + StandardOpenOption.TRUNCATE_EXISTING); + currentSize = Files.size(logFile); + } + + private void closeOutput() { + if (output == null) { + return; + } + try { + output.close(); + } catch (IOException exception) { + reportError("Could not close " + logFile, + exception, ErrorManager.CLOSE_FAILURE); + } finally { + output = null; + } + } } /** Applies the runtime debug preference to subsequent file log records. */ diff --git a/core/src/main/java/kst4contest/utils/ApplicationFileUtils.java b/core/src/main/java/kst4contest/utils/ApplicationFileUtils.java index 1d6da208..c4681981 100644 --- a/core/src/main/java/kst4contest/utils/ApplicationFileUtils.java +++ b/core/src/main/java/kst4contest/utils/ApplicationFileUtils.java @@ -29,6 +29,21 @@ public class ApplicationFileUtils { fileName).toString(); } + /** + * Gets the path of a diagnostic file below the application's debug directory. + * + * @param applicationName application directory name + * @param fileName diagnostic file name + * @return full path below {@code .applicationName/debug} + */ + public static String getDebugFilePath( + final String applicationName, + final String fileName + ) { + return Path.of(getFilePath(applicationName, "debug"), fileName) + .toString(); + } + /** * Copies a resource to the application folder if required. * @param applicationName Name of the application. diff --git a/core/src/main/java/kst4contest/view/map/MapCallsignRawSnapshot.java b/core/src/main/java/kst4contest/view/map/MapCallsignRawSnapshot.java index a2f84da1..1931354b 100644 --- a/core/src/main/java/kst4contest/view/map/MapCallsignRawSnapshot.java +++ b/core/src/main/java/kst4contest/view/map/MapCallsignRawSnapshot.java @@ -60,10 +60,30 @@ public record MapCallsignRawSnapshot( } public String markerLabel() { - String baseLabel = bandSummary.isBlank() - ? displayCallSign - : displayCallSign + " (" + bandSummary + ")"; - return offersSelectedBand ? baseLabel + " B+" : baseLabel; + return displayCallSign; + } + + /** + * Returns the details removed from the visible marker label for its hover text. + * + * @return multiline callsign, locator, band and frequency summary + */ + public String tooltipText() { + StringJoiner joiner = new StringJoiner("\n"); + joiner.add(displayCallSign); + if (!locator6.isBlank()) { + joiner.add("Locator: " + locator6); + } + if (!bandSummary.isBlank()) { + joiner.add("Bands: " + bandSummary); + } + if (offersSelectedBand) { + joiner.add("B+"); + } + if (!lastKnownFrequenciesByBand.isEmpty()) { + joiner.add(detailFrequencyText()); + } + return joiner.toString(); } public String detailFrequencyText() { @@ -77,4 +97,4 @@ public record MapCallsignRawSnapshot( } return joiner.toString(); } -} \ No newline at end of file +} diff --git a/core/src/main/java/kst4contest/view/map/MapMarkerClusterTooltip.java b/core/src/main/java/kst4contest/view/map/MapMarkerClusterTooltip.java index 401f32f5..17275a3c 100644 --- a/core/src/main/java/kst4contest/view/map/MapMarkerClusterTooltip.java +++ b/core/src/main/java/kst4contest/view/map/MapMarkerClusterTooltip.java @@ -26,7 +26,7 @@ public final class MapMarkerClusterTooltip { public static String build(MapMarkerCluster cluster) { List callsigns = new ArrayList<>(); for (MapCallsignRawSnapshot marker : cluster.getMarkers()) { - String label = marker.markerLabel(); + String label = marker.tooltipText().replace('\n', ' '); if (label != null && !label.isBlank()) { callsigns.add(label.trim()); } diff --git a/core/src/main/resources/praktiKSTpreferences.xml b/core/src/main/resources/praktiKSTpreferences.xml index 4656544d..db8d900e 100644 --- a/core/src/main/resources/praktiKSTpreferences.xml +++ b/core/src/main/resources/praktiKSTpreferences.xml @@ -1,6 +1,6 @@ - 8 + 9 DO5AMF changeMe @@ -13,6 +13,7 @@ 60.0 1333.0 135.0 + false true true true diff --git a/core/src/test/java/kst4contest/controller/ChatControllerNotQrvPersistenceTest.java b/core/src/test/java/kst4contest/controller/ChatControllerNotQrvPersistenceTest.java new file mode 100644 index 00000000..07faf3b6 --- /dev/null +++ b/core/src/test/java/kst4contest/controller/ChatControllerNotQrvPersistenceTest.java @@ -0,0 +1,40 @@ +package kst4contest.controller; + +import kst4contest.model.ChatCategory; +import kst4contest.model.ChatMember; +import org.junit.jupiter.api.Test; + +import java.sql.SQLException; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class ChatControllerNotQrvPersistenceTest { + + @Test + void propagatedNotQrvStateIsPersistedForTheSharedRawCall() throws SQLException { + DBController database = mock(DBController.class); + ChatController controller = new ChatController(); + controller.setDbHandler(database); + ChatMember selected = member("9A0BB-2", 2); + ChatMember second = member("9A0BB-70", 3); + controller.addOrUpdateActiveChatMember(selected); + controller.addOrUpdateActiveChatMember(second); + selected.setQrv432(false); + + controller.propagateNotQrvStateToActiveMembers(selected); + + assertFalse(second.isQrv432()); + verify(database).updateNotQRVInfoOnChatMember(argThat(member -> + "9A0BB".equals(member.getCallSignRaw()) && !member.isQrv432())); + } + + private static ChatMember member(String callSign, int category) { + ChatMember member = new ChatMember(); + member.setCallSign(callSign); + member.setChatCategory(new ChatCategory(category)); + return member; + } +} diff --git a/core/src/test/java/kst4contest/controller/ChatControllerRecentJoinTest.java b/core/src/test/java/kst4contest/controller/ChatControllerRecentJoinTest.java new file mode 100644 index 00000000..f6075644 --- /dev/null +++ b/core/src/test/java/kst4contest/controller/ChatControllerRecentJoinTest.java @@ -0,0 +1,72 @@ +package kst4contest.controller; + +import kst4contest.model.ChatCategory; +import kst4contest.model.ChatMember; +import kst4contest.model.RecentChatJoin; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ChatControllerRecentJoinTest { + + @Test + void liveJoinIsRecordedOnceWithoutRenewingTheTimestampOnDuplicateUpdates() { + ChatController controller = new ChatController(); + + controller.addOrUpdateActiveChatMember(member("HB9SJV", 2), 1_000L); + controller.addOrUpdateActiveChatMember(member("HB9SJV", 2), 9_000L); + + List joins = controller.getRecentChatJoins().snapshot(); + assertEquals(1, joins.size()); + assertEquals("HB9SJV", joins.get(0).getCallSign()); + assertEquals(2, joins.get(0).getCategoryNumber()); + assertEquals(1_000L, joins.get(0).getJoinedAtEpochMillis()); + } + + @Test + void theSameCallsignInTwoCategoriesRemainsTwoJoinIdentities() { + ChatController controller = new ChatController(); + + controller.addOrUpdateActiveChatMember(member("DL1KDA", 2), 1_000L); + controller.addOrUpdateActiveChatMember(member("DL1KDA", 3), 2_000L); + + List joins = controller.getRecentChatJoins().snapshot(); + assertEquals(2, joins.size()); + assertEquals(List.of(3, 2), joins.stream().map(RecentChatJoin::getCategoryNumber).toList()); + } + + @Test + void leavingRemovesTheJoinAndARealRejoinStartsAFreshTimestamp() { + ChatController controller = new ChatController(); + ChatMember member = member("OE5SOS", 2); + + controller.addOrUpdateActiveChatMember(member, 1_000L); + controller.removeActiveChatMember(member); + assertTrue(controller.getRecentChatJoins().isEmpty()); + + controller.addOrUpdateActiveChatMember(member("OE5SOS", 2), 7_000L); + + assertEquals(7_000L, + controller.getRecentChatJoins().snapshot().get(0).getJoinedAtEpochMillis()); + } + + @Test + void clearingTheActiveRosterAlsoClearsTransientJoinEvents() { + ChatController controller = new ChatController(); + controller.addOrUpdateActiveChatMember(member("PA2M", 2), 1_000L); + + controller.clearActiveChatMembers(); + + assertTrue(controller.getRecentChatJoins().isEmpty()); + } + + private static ChatMember member(String callSign, int categoryNumber) { + ChatMember member = new ChatMember(); + member.setCallSign(callSign); + member.setChatCategory(new ChatCategory(categoryNumber)); + return member; + } +} diff --git a/core/src/test/java/kst4contest/controller/ChatControllerWorkedActionTest.java b/core/src/test/java/kst4contest/controller/ChatControllerWorkedActionTest.java new file mode 100644 index 00000000..7b1650cc --- /dev/null +++ b/core/src/test/java/kst4contest/controller/ChatControllerWorkedActionTest.java @@ -0,0 +1,66 @@ +package kst4contest.controller; + +import kst4contest.model.Band; +import kst4contest.model.ChatCategory; +import kst4contest.model.ChatMember; +import org.junit.jupiter.api.Test; + +import java.sql.SQLException; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class ChatControllerWorkedActionTest { + + @Test + void manualWorkedActionUsesContactInfoPersistenceAndUpdatesEveryActiveVariant() + throws SQLException { + DBController database = mock(DBController.class); + when(database.updateWkdInfoOnChatMember(argThat(member -> member != null))) + .thenReturn(true); + ChatController controller = new ChatController(); + controller.setDbHandler(database); + ChatMember selected = member("9A0BB-2", 2, "JN75AA"); + ChatMember secondCategory = member("9A0BB-70", 3, "JN75AA"); + controller.addOrUpdateActiveChatMember(selected); + controller.addOrUpdateActiveChatMember(secondCategory); + + assertTrue(controller.markStationWorkedOnBand(selected, Band.B_432)); + + assertTrue(selected.isWorked()); + assertTrue(selected.isWorked432()); + assertTrue(secondCategory.isWorked()); + assertTrue(secondCategory.isWorked432()); + assertTrue(controller.getWorkedGrossFieldCache() + .isGrossFieldWorked(Band.B_432, "JN75")); + verify(database).updateWkdInfoOnChatMember(argThat(member -> + "9A0BB".equals(member.getCallSignRaw()) + && member.isWorked() + && member.isWorked432() + && "JN75AA".equals(member.getQra()))); + verify(database).upsertWorkedGrossField( + Band.B_432, "JN75AA", "9A0BB", "MANUAL"); + } + + @Test + void manualWorkedActionRejectsMissingStationOrBandWithoutChangingState() { + ChatController controller = new ChatController(); + ChatMember member = member("DL1ABC", 2, "JO50AA"); + + assertFalse(controller.markStationWorkedOnBand(null, Band.B_144)); + assertFalse(controller.markStationWorkedOnBand(member, null)); + assertFalse(member.isWorked()); + } + + private static ChatMember member(String callSign, int category, String locator) { + ChatMember member = new ChatMember(); + member.setCallSign(callSign); + member.setChatCategory(new ChatCategory(category)); + member.setQra(locator); + return member; + } +} diff --git a/core/src/test/java/kst4contest/controller/ExternalLoggedQsoTest.java b/core/src/test/java/kst4contest/controller/ExternalLoggedQsoTest.java index bfe85811..ea90a27e 100644 --- a/core/src/test/java/kst4contest/controller/ExternalLoggedQsoTest.java +++ b/core/src/test/java/kst4contest/controller/ExternalLoggedQsoTest.java @@ -8,6 +8,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.List; import java.util.stream.Stream; +import kst4contest.model.Band; import kst4contest.model.ChatMember; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -37,6 +38,15 @@ class ExternalLoggedQsoTest { assertNull(LoggedQsoBand.fromWinTestBandId("99")); } + @Test + void resolvesEveryProjectBandForManualWorkedActions() { + for (Band band : Band.values()) { + LoggedQsoBand loggedBand = LoggedQsoBand.fromProjectBand(band); + + assertEquals(band, loggedBand.getProjectBand()); + } + } + @Test void missingCallsignIsRejectedBeforeAnyStateIsBuilt() { assertTrue(ExternalLoggedQso.create(null, LoggedQsoBand.BAND_144, "JO50AA", "TEST").isEmpty()); diff --git a/core/src/test/java/kst4contest/controller/ExternalLoggerListenerValidationTest.java b/core/src/test/java/kst4contest/controller/ExternalLoggerListenerValidationTest.java index e1fc723d..8b8c6d00 100644 --- a/core/src/test/java/kst4contest/controller/ExternalLoggerListenerValidationTest.java +++ b/core/src/test/java/kst4contest/controller/ExternalLoggerListenerValidationTest.java @@ -41,7 +41,7 @@ class ExternalLoggerListenerValidationTest { assertDoesNotThrow(() -> listener.processUCXUDPMessage(packet)); - verify(controller, never()).applyExternalLoggedQso(any()); + verify(controller, never()).recordLoggedQso(any(), any()); verify(controller, never()).getDbHandler(); } @@ -55,7 +55,7 @@ class ExternalLoggerListenerValidationTest { assertDoesNotThrow(() -> listener.processWinTestMessage(packet)); - verify(controller, never()).applyExternalLoggedQso(any()); + verify(controller, never()).recordLoggedQso(any(), any()); verify(controller, never()).getDbHandler(); } } diff --git a/core/src/test/java/kst4contest/controller/MessageBusManagementThreadChatFrameTest.java b/core/src/test/java/kst4contest/controller/MessageBusManagementThreadChatFrameTest.java index 2e0fa608..b25c8214 100644 --- a/core/src/test/java/kst4contest/controller/MessageBusManagementThreadChatFrameTest.java +++ b/core/src/test/java/kst4contest/controller/MessageBusManagementThreadChatFrameTest.java @@ -63,6 +63,7 @@ class MessageBusManagementThreadChatFrameTest { private static final String RECEIVER_LOCATOR = "JO70LL"; private static final double MAX_QRB_KM = 900.0; private static final double BEAM_WIDTH_DEG = 50.0; + private static final long FIXED_NOW_MILLIS = 1_700_000_123_456L; @TempDir Path tempDir; @@ -102,6 +103,10 @@ class MessageBusManagementThreadChatFrameTest { .thenReturn(mock(StationMetricsService.class)); when(controller.getScoreService()).thenReturn(mock(ScoreService.class)); when(controller.getDxClusterServer()).thenReturn(dxClusterServer); + DBController database = mock(DBController.class); + when(controller.getDbHandler()).thenReturn(database); + when(database.fetchChatMemberWkdDataForOnlyOneCallsignFromDB(any(ChatMember.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); when(controller.findActiveChatMember(any(ChatMember.class))) .thenAnswer(invocation -> activeMemberFor(invocation.getArgument(0))); } @@ -146,6 +151,95 @@ class MessageBusManagementThreadChatFrameTest { verifyNoInteractions(playAudioUtils); } + @Test + @Timeout(10) + void genuinePrivateMessageCarriesTheAuthoritativeRouteFlags() throws InterruptedException { + runThrough(chatFrame("CH", REMOTE_CALL, "pse ur qrg", MY_CALL)); + + ChatMessage published = singlePublished(); + assertEquals(MY_CALL, published.getReceiver().getCallSign()); + assertTrue(published.isMessageDirectedToMe()); + assertFalse(published.isMessageDirectedToCommunity()); + assertEquals(ChatCategory.VUHF, published.getChatCategory().getCategoryNumber()); + } + + @Test + @Timeout(10) + void publicMessageWithLeadingAddressIsMarkedAsExplicitlyAddressedToMe() + throws InterruptedException { + runThrough(chatFrame("CH", REMOTE_CALL, "(" + MY_CALL + ") pse ur qrg", "0")); + + ChatMessage published = singlePublished(); + assertEquals("ALL", published.getReceiver().getCallSign()); + assertTrue(published.isMessageDirectedToCommunity()); + assertTrue(published.isMessageDirectedToMe()); + } + + @Test + @Timeout(10) + void arbitraryPublicMentionIsNotMarkedAsExplicitlyAddressedToMe() + throws InterruptedException { + runThrough(chatFrame("CH", REMOTE_CALL, "ask " + MY_CALL + " about the sked", "0")); + + ChatMessage published = singlePublished(); + assertTrue(published.isMessageDirectedToCommunity()); + assertFalse(published.isMessageDirectedToMe()); + } + + @Test + @Timeout(10) + void liveUserEntryCarriesTheInjectedJoinTimestampToTheController() + throws InterruptedException { + runThrough(message("UA5|2|HB9SJV|Contest team|JN47AA|3|")); + + ArgumentCaptor member = ArgumentCaptor.forClass(ChatMember.class); + verify(controller).addOrUpdateActiveChatMember(member.capture(), + org.mockito.ArgumentMatchers.eq(FIXED_NOW_MILLIS)); + assertEquals("HB9SJV", member.getValue().getCallSign()); + assertEquals(ChatCategory.VUHF, + member.getValue().getChatCategory().getCategoryNumber()); + } + + @Test + @Timeout(10) + void alternateLiveUserEntryCarriesTheInjectedJoinTimestampToTheController() + throws InterruptedException { + runThrough(message("UA2|3|OE5SOS|Microwave team|JN78AA|3|")); + + ArgumentCaptor member = ArgumentCaptor.forClass(ChatMember.class); + verify(controller).addOrUpdateActiveChatMember(member.capture(), + org.mockito.ArgumentMatchers.eq(FIXED_NOW_MILLIS)); + assertEquals("OE5SOS", member.getValue().getCallSign()); + assertEquals(ChatCategory.MICROWAVE, + member.getValue().getChatCategory().getCategoryNumber()); + } + + @Test + @Timeout(10) + void initialUserListAndItsTerminatorDoNotCreateLiveJoinEvents() + throws InterruptedException { + runThrough( + message("UA0|2|HB9SJV|Contest team|JN47AA|3|"), + message("UE|2|1|")); + + verify(controller, never()).addOrUpdateActiveChatMember( + any(ChatMember.class), org.mockito.ArgumentMatchers.anyLong()); + verify(controller).stageInitialOn4KstChatMember( + org.mockito.ArgumentMatchers.eq(7L), any(ChatMember.class)); + verify(controller).onOn4KstInitialUserListCompleted( + org.mockito.ArgumentMatchers.eq(7L), any(ChatCategory.class)); + } + + @Test + @Timeout(10) + void ownCallIsExcludedFromLiveJoinEventsIgnoringCase() + throws InterruptedException { + runThrough(message("UA5|2|dn9apw|Own station|JO50LL|3|")); + + verify(controller, never()).addOrUpdateActiveChatMember( + any(ChatMember.class), org.mockito.ArgumentMatchers.anyLong()); + } + // --- sent by me --------------------------------------------------------- @Test @@ -382,7 +476,8 @@ class MessageBusManagementThreadChatFrameTest { mock(ThreadStatusCallback.class), 7L, rxQueue, - ignored -> true); + ignored -> true, + () -> FIXED_NOW_MILLIS); for (ChatMessage frame : frames) { rxQueue.add(frame); } diff --git a/core/src/test/java/kst4contest/controller/MessageHistoryRecorderTest.java b/core/src/test/java/kst4contest/controller/MessageHistoryRecorderTest.java index 5c58ffbb..5c98f755 100644 --- a/core/src/test/java/kst4contest/controller/MessageHistoryRecorderTest.java +++ b/core/src/test/java/kst4contest/controller/MessageHistoryRecorderTest.java @@ -146,6 +146,25 @@ class MessageHistoryRecorderTest { }); } + @Test + void dedicatedDebugDirectoryIsCreatedWithoutMovingLegacyHistory() + throws Exception { + Path legacyHistory = historyFile(); + Files.writeString(legacyHistory, "legacy history\n"); + Path debugDirectory = temporaryDirectory.resolve("debug"); + + try (MessageHistoryRecorder recorder = new MessageHistoryRecorder( + debugDirectory, + true, + Clock.fixed(FIXED_INSTANT, ZoneOffset.UTC))) { + recorder.recordRx("OK|"); + } + + assertEquals("legacy history\n", Files.readString(legacyHistory)); + assertTrue(Files.isRegularFile( + debugDirectory.resolve("Messagehistory.raw"))); + } + private MessageHistoryRecorder recorder(boolean enabled) { return new MessageHistoryRecorder( temporaryDirectory, diff --git a/core/src/test/java/kst4contest/controller/ReadUDPbyUCXMessageThreadLoopbackTest.java b/core/src/test/java/kst4contest/controller/ReadUDPbyUCXMessageThreadLoopbackTest.java index 76cc0f06..63c900e1 100644 --- a/core/src/test/java/kst4contest/controller/ReadUDPbyUCXMessageThreadLoopbackTest.java +++ b/core/src/test/java/kst4contest/controller/ReadUDPbyUCXMessageThreadLoopbackTest.java @@ -25,7 +25,6 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; -import kst4contest.model.Band; import kst4contest.model.ChatMember; import kst4contest.model.ChatPreferences; import kst4contest.model.ThreadStateMessage; @@ -145,12 +144,12 @@ class ReadUDPbyUCXMessageThreadLoopbackTest { } /** - * A UCXLog QSO packet marks the station worked, registers the gross field - * and stores an unknown station in the database and the UDP backup file. + * A UCXLog QSO packet delegates the complete contact to the shared controller + * action and writes the derived row to the UDP backup file. * Bytes before the XML declaration are transport framing and are dropped. */ @Test - void contactInfoPacketMarksTheStationWorkedAndStoresIt(@TempDir Path tempDir) throws Exception { + void contactInfoPacketUsesSharedWorkedActionAndWritesBackup(@TempDir Path tempDir) throws Exception { Path backupFile = tempDir.resolve("udp-backup.txt"); DBController database = givenQsoProcessingEnabled(backupFile); when(database.updateWkdInfoOnChatMember(any())).thenReturn(false); @@ -164,24 +163,13 @@ class ReadUDPbyUCXMessageThreadLoopbackTest { + ""); ArgumentCaptor qso = ArgumentCaptor.forClass(ExternalLoggedQso.class); - verify(controller).applyExternalLoggedQso(qso.capture()); + verify(controller).recordLoggedQso(qso.capture(), any(ChatMember.class)); assertEquals("OK1ABC", qso.getValue().getCallSign()); assertEquals(LoggedQsoBand.BAND_144, qso.getValue().getBand()); assertEquals("JO70FD", qso.getValue().getLocator()); assertEquals("UCXLOG", qso.getValue().getSource()); - verify(controller).registerWorkedGrossField( - eq(Band.B_144), eq("JO70FD"), any(ChatMember.class), eq("UCXLOG")); - - ArgumentCaptor stored = ArgumentCaptor.forClass(ChatMember.class); - verify(database).storeChatMember(stored.capture()); - assertEquals("OK1ABC", stored.getValue().getCallSign()); - assertEquals("unknown", stored.getValue().getName()); - assertEquals("JO70FD", stored.getValue().getQra()); - assertTrue(stored.getValue().isWorked()); - assertTrue(stored.getValue().isWorked144()); - String backup = Files.readString(backupFile, StandardCharsets.UTF_8); - assertTrue(backup.startsWith("\nOK1ABC;unknown;JO70FD;"), backup); + assertTrue(backup.startsWith("\nOK1ABC;null;JO70FD;"), backup); } /** @@ -198,10 +186,10 @@ class ReadUDPbyUCXMessageThreadLoopbackTest { + "DXLog432SP2XYZ" + "042JO93AB"); - verify(controller).registerWorkedGrossField( - eq(Band.B_432), eq("JO93AB"), any(ChatMember.class), eq("UCXLOG")); - verify(database).updateWkdInfoOnChatMember(any()); - verify(database, never()).storeChatMember(any()); + ArgumentCaptor qso = ArgumentCaptor.forClass(ExternalLoggedQso.class); + verify(controller).recordLoggedQso(qso.capture(), any(ChatMember.class)); + assertEquals(LoggedQsoBand.BAND_432, qso.getValue().getBand()); + assertEquals("JO93AB", qso.getValue().getLocator()); } @Test @@ -211,7 +199,7 @@ class ReadUDPbyUCXMessageThreadLoopbackTest { sendThenAwaitMarker("144OK1ABC"); - verify(controller, never()).applyExternalLoggedQso(any()); + verify(controller, never()).recordLoggedQso(any(), any()); verify(database, never()).updateWkdInfoOnChatMember(any()); } @@ -229,8 +217,7 @@ class ReadUDPbyUCXMessageThreadLoopbackTest { sendThenAwaitMarker("144OK1ABC" + "JO70FD"); - verify(controller).applyExternalLoggedQso(any()); - verify(database).storeChatMember(any()); + verify(controller).recordLoggedQso(any(), any()); } /** A RadioInfo without Freq used to end the listener with a NullPointerException. */ @@ -250,7 +237,7 @@ class ReadUDPbyUCXMessageThreadLoopbackTest { @Test void runtimeExceptionWhileProcessingDropsOnlyThatPacket(@TempDir Path tempDir) throws Exception { givenQsoProcessingEnabled(tempDir.resolve("udp-backup.txt")); - doThrow(new IllegalStateException("boom")).when(controller).applyExternalLoggedQso(any()); + doThrow(new IllegalStateException("boom")).when(controller).recordLoggedQso(any(), any()); sendThenAwaitMarker("144OK1ABC"); @@ -297,7 +284,7 @@ class ReadUDPbyUCXMessageThreadLoopbackTest { @Test void failingStatusCallbackDoesNotEndTheListener(@TempDir Path tempDir) throws Exception { givenQsoProcessingEnabled(tempDir.resolve("udp-backup.txt")); - doThrow(new IllegalStateException("boom")).when(controller).applyExternalLoggedQso(any()); + doThrow(new IllegalStateException("boom")).when(controller).recordLoggedQso(any(), any()); statusCallbackThrows = true; sendThenAwaitMarker("144OK1ABC"); diff --git a/core/src/test/java/kst4contest/controller/WorkedGrossFieldCacheTest.java b/core/src/test/java/kst4contest/controller/WorkedGrossFieldCacheTest.java new file mode 100644 index 00000000..11ca2a87 --- /dev/null +++ b/core/src/test/java/kst4contest/controller/WorkedGrossFieldCacheTest.java @@ -0,0 +1,25 @@ +package kst4contest.controller; + +import kst4contest.model.Band; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class WorkedGrossFieldCacheTest { + + @Test + void anyBandSnapshotContainsUniqueNormalizedGrossFieldsAndIsImmutable() { + WorkedGrossFieldCache cache = new WorkedGrossFieldCache(); + cache.addWorked(Band.B_144, "jo51aa"); + cache.addWorked(Band.B_432, "JO51BB"); + cache.addWorked(Band.B_1296, "JN49AA"); + + Set snapshot = cache.snapshotWorkedGrossFieldsAny(); + + assertEquals(Set.of("JO51", "JN49"), snapshot); + assertThrows(UnsupportedOperationException.class, () -> snapshot.add("JO50")); + } +} diff --git a/core/src/test/java/kst4contest/model/ChatPreferencesQtfHeadingTest.java b/core/src/test/java/kst4contest/model/ChatPreferencesQtfHeadingTest.java new file mode 100644 index 00000000..a6679608 --- /dev/null +++ b/core/src/test/java/kst4contest/model/ChatPreferencesQtfHeadingTest.java @@ -0,0 +1,195 @@ +package kst4contest.model; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.nio.file.Path; +import java.nio.file.Files; +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ChatPreferencesQtfHeadingTest { + + @TempDir + Path directory; + + @Test + void loadedQtfDefaultInitializesTheRuntimeHeadingOnce() { + Path file = directory.resolve("preferences.xml"); + ChatPreferences written = new ChatPreferences(file); + written.setStn_qtfDefault(247.5); + assertTrue(written.writePreferencesToXmlFile()); + + ChatPreferences restored = new ChatPreferences(file); + assertTrue(restored.readPreferencesFromXmlFile()); + assertEquals(247.5, restored.getActualQTF().get(), 0.0001); + + restored.getActualQTF().set(91.0); + assertTrue(restored.readPreferencesFromXmlFile()); + assertEquals(91.0, restored.getActualQTF().get(), 0.0001, + "reloading preferences overwrote a later runtime rotor heading"); + } + + @Test + void eachProfileStartsWithItsOwnQtfDefault() { + Path rootFile = directory.resolve("preferences.xml"); + Path operatorFile = directory.resolve("profiles/OP2/preferences.xml"); + writeQtfDefault(rootFile, 135.0); + writeQtfDefault(operatorFile, 302.0); + + ChatPreferences root = read(rootFile); + ChatPreferences operator = read(operatorFile); + + assertEquals(135.0, root.getActualQTF().get(), 0.0001); + assertEquals(302.0, operator.getActualQTF().get(), 0.0001); + } + + @Test + void qtfFilterHeadingSynchronizationDefaultsToEnabledForRootAndOperatorProfiles() { + ChatPreferences root = new ChatPreferences(directory.resolve("preferences.xml")); + ChatPreferences operator = new ChatPreferences(directory.resolve("profiles/OP2/preferences.xml")); + + assertTrue(root.isStn_useQtfFilterDirectionAsAntennaHeading()); + assertTrue(root.isQtfFilterDirectionAsAntennaHeadingEnabled()); + assertTrue(operator.isStn_useQtfFilterDirectionAsAntennaHeading()); + assertTrue(operator.isQtfFilterDirectionAsAntennaHeadingEnabled()); + } + + @Test + void primaryRotatorOnlySuspendsTheStoredManualHeadingPreference() { + ChatPreferences preferences = new ChatPreferences(directory.resolve("preferences.xml")); + + preferences.setStn_pstRotatorEnabled(true); + + assertTrue(preferences.isStn_useQtfFilterDirectionAsAntennaHeading()); + assertFalse(preferences.isQtfFilterDirectionAsAntennaHeadingEnabled()); + + preferences.setStn_pstRotatorEnabled(false); + + assertTrue(preferences.isStn_useQtfFilterDirectionAsAntennaHeading()); + assertTrue(preferences.isQtfFilterDirectionAsAntennaHeadingEnabled()); + } + + @Test + void versionEightProfilesMigrateToTheEnabledDefault() throws IOException { + Path file = directory.resolve("preferences.xml"); + Files.writeString(file, """ + + + 8 + + false + false + + + """); + + ChatPreferences migrated = read(file); + + assertTrue(migrated.isStn_useQtfFilterDirectionAsAntennaHeading()); + assertTrue(migrated.isQtfFilterDirectionAsAntennaHeadingEnabled()); + + assertTrue(migrated.writePreferencesToXmlFile()); + String migratedXml = Files.readString(file); + assertTrue(migratedXml.contains("9")); + assertTrue(migratedXml.contains( + "false")); + assertTrue(read(file).isQtfFilterDirectionAsAntennaHeadingEnabled()); + } + + @Test + void explicitOptOutSurvivesRestartAndPrimaryRotatorChanges() throws IOException { + Path file = directory.resolve("preferences.xml"); + ChatPreferences preferences = new ChatPreferences(file); + preferences.setStn_useQtfFilterDirectionAsAntennaHeading(false); + assertTrue(preferences.writePreferencesToXmlFile()); + + ChatPreferences restored = read(file); + assertFalse(restored.isStn_useQtfFilterDirectionAsAntennaHeading()); + assertFalse(restored.isQtfFilterDirectionAsAntennaHeadingEnabled()); + assertTrue(Files.readString(file).contains( + "true")); + + restored.setStn_pstRotatorEnabled(true); + restored.setStn_pstRotatorEnabled(false); + + assertFalse(restored.isStn_useQtfFilterDirectionAsAntennaHeading()); + assertFalse(restored.isQtfFilterDirectionAsAntennaHeadingEnabled()); + } + + @Test + void profilesKeepIndependentManualHeadingDecisions() { + Path rootFile = directory.resolve("preferences.xml"); + Path operatorFile = directory.resolve("profiles/OP2/preferences.xml"); + ChatPreferences root = new ChatPreferences(rootFile); + root.setStn_useQtfFilterDirectionAsAntennaHeading(false); + assertTrue(root.writePreferencesToXmlFile()); + + ChatPreferences operator = new ChatPreferences(operatorFile); + assertTrue(operator.writePreferencesToXmlFile()); + + assertFalse(read(rootFile).isStn_useQtfFilterDirectionAsAntennaHeading()); + assertTrue(read(operatorFile).isStn_useQtfFilterDirectionAsAntennaHeading()); + } + + @Test + void laterRotorHeadingReplacesTheLoadedStartValue() { + Path file = directory.resolve("preferences.xml"); + ChatPreferences written = new ChatPreferences(file); + written.setStn_qtfDefault(188.0); + written.setStn_pstRotatorEnabled(true); + assertTrue(written.writePreferencesToXmlFile()); + + ChatPreferences restored = read(file); + restored.getActualQTF().set(271.25); + + assertEquals(271.25, restored.getActualQTF().get(), 0.0001); + assertEquals(188.0, restored.getStn_qtfDefault(), 0.0001, + "a runtime heading changed the persisted start direction"); + } + + @ParameterizedTest + @ValueSource(strings = {"NaN", "Infinity", "-0.1", "360.1"}) + void invalidStoredQtfDefaultsAreIgnored(String invalidValue) throws IOException { + Path file = directory.resolve("preferences.xml"); + ChatPreferences written = new ChatPreferences(file); + written.setStn_qtfDefault(247.5); + assertTrue(written.writePreferencesToXmlFile()); + Files.writeString(file, Files.readString(file).replace( + "247.5", + "" + invalidValue + "")); + + ChatPreferences restored = read(file); + + assertEquals(135.0, restored.getStn_qtfDefault(), 0.0001); + assertEquals(135.0, restored.getActualQTF().get(), 0.0001); + } + + @ParameterizedTest + @ValueSource(strings = {"NaN", "Infinity", "-Infinity", "-0.1", "360.1"}) + void invalidQtfDefaultsCannotReplaceAValidValue(String invalidValue) { + ChatPreferences preferences = new ChatPreferences(directory.resolve("preferences.xml")); + preferences.setStn_qtfDefault(247.5); + + preferences.setStn_qtfDefault(Double.parseDouble(invalidValue)); + + assertEquals(247.5, preferences.getStn_qtfDefault(), 0.0001); + } + + private void writeQtfDefault(Path file, double qtf) { + ChatPreferences preferences = new ChatPreferences(file); + preferences.setStn_qtfDefault(qtf); + assertTrue(preferences.writePreferencesToXmlFile()); + } + + private ChatPreferences read(Path file) { + ChatPreferences preferences = new ChatPreferences(file); + assertTrue(preferences.readPreferencesFromXmlFile()); + return preferences; + } +} diff --git a/core/src/test/java/kst4contest/model/ChatPreferencesRoundTripTest.java b/core/src/test/java/kst4contest/model/ChatPreferencesRoundTripTest.java index 97c1a190..89575b18 100644 --- a/core/src/test/java/kst4contest/model/ChatPreferencesRoundTripTest.java +++ b/core/src/test/java/kst4contest/model/ChatPreferencesRoundTripTest.java @@ -181,6 +181,8 @@ class ChatPreferencesRoundTripTest { assertTrue(fromTemplate.getLst_txtShortCutBtnList().snapshot().contains("sked")); assertTrue(fromTemplate.isStn_bandActive144()); assertFalse(fromTemplate.isStn_bandActive10G()); + assertTrue(fromTemplate.isStn_useQtfFilterDirectionAsAntennaHeading()); + assertTrue(fromTemplate.isQtfFilterDirectionAsAntennaHeadingEnabled()); } @Test @@ -211,6 +213,8 @@ class ChatPreferencesRoundTripTest { assertEquals("JO31AA", legacy.getStn_loginLocatorMainCat()); assertEquals(3, legacy.getLoginChatCategoryMain().getCategoryNumber()); assertEquals(8123, legacy.getNotify_dxclusterServerPort()); + assertTrue(legacy.isStn_useQtfFilterDirectionAsAntennaHeading()); + assertTrue(legacy.isQtfFilterDirectionAsAntennaHeadingEnabled()); // A tag that is missing keeps the compiled-in default. assertEquals(new ChatPreferences(legacyFile).getStn_on4kstServersPort(), legacy.getStn_on4kstServersPort()); diff --git a/core/src/test/java/kst4contest/test/ChatPreferencesDebugModeTest.java b/core/src/test/java/kst4contest/test/ChatPreferencesDebugModeTest.java index e3251745..4f3048ec 100644 --- a/core/src/test/java/kst4contest/test/ChatPreferencesDebugModeTest.java +++ b/core/src/test/java/kst4contest/test/ChatPreferencesDebugModeTest.java @@ -54,7 +54,7 @@ class ChatPreferencesDebugModeTest { assertTrue(written.writePreferencesToXmlFile()); String writtenXml = Files.readString(preferencesFile); - assertTrue(writtenXml.contains("8")); + assertTrue(writtenXml.contains("9")); assertTrue(writtenXml.contains( "false" + "")); diff --git a/core/src/test/java/kst4contest/test/ChatPreferencesLayoutPersistenceTest.java b/core/src/test/java/kst4contest/test/ChatPreferencesLayoutPersistenceTest.java index f46d9f46..ca9b3b74 100644 --- a/core/src/test/java/kst4contest/test/ChatPreferencesLayoutPersistenceTest.java +++ b/core/src/test/java/kst4contest/test/ChatPreferencesLayoutPersistenceTest.java @@ -106,6 +106,8 @@ class ChatPreferencesLayoutPersistenceTest { preferences.getGUIscn_ChatwindowMainSceneSizeHW()[0] = 812; preferences.getGUIscn_ChatwindowMainSceneSizeHW()[1] = 1340; preferences.setGUIstationMapClusteringEnabled(false); + preferences.setGUIselectedCallSignSplitPane_dividerposition( + new double[] {0.68}); preferences.setTableColumnWidth("qso-other-monitor", "call-tx", 123.75); assertTrue(preferences.writeLayoutPreferencesToXmlFile()); @@ -115,7 +117,7 @@ class ChatPreferencesLayoutPersistenceTest { assertFalse(writtenXml.contains("UNSAVED-CALL")); assertTrue(writtenXml.contains("")); assertTrue(writtenXml.contains("untouched")); - assertTrue(writtenXml.contains("8")); + assertTrue(writtenXml.contains("9")); assertTrue(writtenXml.contains("812.0;1340.0")); assertTrue(writtenXml.contains("false" + "")); @@ -124,6 +126,8 @@ class ChatPreferencesLayoutPersistenceTest { assertTrue(restored.readPreferencesFromXmlFile()); assertEquals("SAVED-CALL", restored.getStn_loginCallSign()); assertFalse(restored.isGUIstationMapClusteringEnabled()); + assertArrayEquals(new double[] {0.68}, + restored.getGUIselectedCallSignSplitPane_dividerposition(), 0.0001); assertEquals(123.75, restored.getTableColumnWidth("qso-other-monitor", "call-tx").orElseThrow()); } diff --git a/core/src/test/java/kst4contest/test/ChatPreferencesStationMapAlwaysOnTopTest.java b/core/src/test/java/kst4contest/test/ChatPreferencesStationMapAlwaysOnTopTest.java new file mode 100644 index 00000000..b7933c0a --- /dev/null +++ b/core/src/test/java/kst4contest/test/ChatPreferencesStationMapAlwaysOnTopTest.java @@ -0,0 +1,82 @@ +package kst4contest.test; + +import kst4contest.model.ChatPreferences; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ChatPreferencesStationMapAlwaysOnTopTest { + + @TempDir + Path temporaryDirectory; + + @Test + void alwaysOnTopIsDisabledByDefault() { + assertFalse(new ChatPreferences(temporaryDirectory.resolve("unused.xml")) + .isGUIstationMapAlwaysOnTop()); + } + + @Test + void enabledAlwaysOnTopSurvivesFullRoundTrip() throws IOException { + Path preferencesFile = temporaryDirectory.resolve("preferences.xml"); + ChatPreferences written = new ChatPreferences(preferencesFile); + written.setGUIstationMapAlwaysOnTop(true); + + assertTrue(written.writePreferencesToXmlFile()); + assertTrue(Files.readString(preferencesFile).contains( + "true")); + + ChatPreferences restored = new ChatPreferences(preferencesFile); + assertTrue(restored.readPreferencesFromXmlFile()); + assertTrue(restored.isGUIstationMapAlwaysOnTop()); + } + + @Test + void missingOrMalformedValueKeepsAlwaysOnTopDisabled() throws IOException { + Path preferencesFile = temporaryDirectory.resolve("legacy.xml"); + Files.writeString(preferencesFile, """ + + + 8 + + sometimes + + + """); + + ChatPreferences restored = new ChatPreferences(preferencesFile); + restored.setGUIstationMapAlwaysOnTop(true); + assertTrue(restored.readPreferencesFromXmlFile()); + assertFalse(restored.isGUIstationMapAlwaysOnTop()); + } + + @Test + void selectiveLayoutWritePersistsAlwaysOnTopWithoutFunctionalSettings() throws IOException { + Path preferencesFile = temporaryDirectory.resolve("selective.xml"); + Files.writeString(preferencesFile, """ + + + 8 + SAVED-CALL + + + """); + ChatPreferences preferences = new ChatPreferences(preferencesFile); + assertTrue(preferences.readPreferencesFromXmlFile()); + preferences.setStn_loginCallSign("UNSAVED-CALL"); + preferences.setGUIstationMapAlwaysOnTop(true); + + assertTrue(preferences.writeLayoutPreferencesToXmlFile()); + + String xml = Files.readString(preferencesFile); + assertTrue(xml.contains("SAVED-CALL")); + assertFalse(xml.contains("UNSAVED-CALL")); + assertTrue(xml.contains("true")); + } +} diff --git a/core/src/test/java/kst4contest/test/ChatPreferencesStationMapClusteringTest.java b/core/src/test/java/kst4contest/test/ChatPreferencesStationMapClusteringTest.java index 8b6c0fae..a4595ba5 100644 --- a/core/src/test/java/kst4contest/test/ChatPreferencesStationMapClusteringTest.java +++ b/core/src/test/java/kst4contest/test/ChatPreferencesStationMapClusteringTest.java @@ -32,7 +32,7 @@ class ChatPreferencesStationMapClusteringTest { assertTrue(written.writePreferencesToXmlFile()); String writtenXml = Files.readString(preferencesFile); - assertTrue(writtenXml.contains("8")); + assertTrue(writtenXml.contains("9")); assertTrue(writtenXml.contains("false" + "")); diff --git a/core/src/test/java/kst4contest/utils/ApplicationFileLoggingTest.java b/core/src/test/java/kst4contest/utils/ApplicationFileLoggingTest.java index 2b96388b..1ba7f764 100644 --- a/core/src/test/java/kst4contest/utils/ApplicationFileLoggingTest.java +++ b/core/src/test/java/kst4contest/utils/ApplicationFileLoggingTest.java @@ -1,11 +1,15 @@ package kst4contest.utils; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.logging.Level; import java.util.logging.Logger; @@ -47,4 +51,87 @@ class ApplicationFileLoggingTest { assertTrue(log.contains("error while enabled")); assertTrue(log.contains("Z\tWARNING\t")); } + + @Test + void createsTheDebugDirectoryOnDemand() throws Exception { + Path logFile = temporaryDirectory.resolve("debug/kst4contest-errors.log"); + Logger logger = Logger.getLogger( + "kst4contest.test.filelogging." + UUID.randomUUID()); + + try (ApplicationFileLogging logging = + new ApplicationFileLogging(logFile, false)) { + logger.warning("directory test"); + } + + assertTrue(Files.isRegularFile(logFile)); + } + + @Test + void rotatesAtTheConfiguredLimitAndKeepsFiveFiles() throws Exception { + Path logFile = temporaryDirectory.resolve("debug/kst4contest-errors.log"); + Logger logger = Logger.getLogger( + "kst4contest.test.filelogging." + UUID.randomUUID()); + + try (ApplicationFileLogging logging = + new ApplicationFileLogging(logFile, true, 320L, 5)) { + for (int index = 0; index < 40; index++) { + logger.info("rotation entry " + index + " " + "x".repeat(120)); + } + } + + assertTrue(Files.isRegularFile(logFile)); + for (int generation = 1; generation < 5; generation++) { + assertTrue(Files.isRegularFile( + logFile.resolveSibling(logFile.getFileName() + "." + generation))); + } + try (var files = Files.list(logFile.getParent())) { + assertEquals(5L, files.filter(path -> path.getFileName().toString() + .startsWith("kst4contest-errors.log")).count()); + } + } + + @Test + void anUnusableLogFileIsReportedWithoutChangingTheLoggerLevel() throws Exception { + Path logFile = temporaryDirectory.resolve("debug/kst4contest-errors.log"); + Files.createDirectories(logFile); + + Logger appLogger = Logger.getLogger("kst4contest"); + Level previousLevel = appLogger.getLevel(); + appLogger.setLevel(Level.SEVERE); + + try { + assertThrows(IOException.class, () -> new ApplicationFileLogging( + logFile, false)); + assertEquals(Level.SEVERE, appLogger.getLevel()); + } finally { + appLogger.setLevel(previousLevel); + } + } + + @Test + void continuesWritingAfterATransientRotationFailure() throws Exception { + Path logFile = temporaryDirectory.resolve("debug/kst4contest-errors.log"); + Logger logger = Logger.getLogger( + "kst4contest.test.filelogging." + UUID.randomUUID()); + AtomicBoolean failNextRotation = new AtomicBoolean(true); + + try (ApplicationFileLogging logging = new ApplicationFileLogging( + logFile, true, 320L, 5, + ignored -> { + if (failNextRotation.getAndSet(false)) { + throw new IOException("simulated transient rotation failure"); + } + })) { + logger.info("before rotation " + "x".repeat(180)); + logger.info("written despite failed rotation " + "x".repeat(180)); + logger.info("written after rotation recovery " + "x".repeat(180)); + } + + Path firstGeneration = logFile.resolveSibling( + logFile.getFileName() + ".1"); + assertTrue(Files.readString(firstGeneration) + .contains("written despite failed rotation")); + assertTrue(Files.readString(logFile) + .contains("written after rotation recovery")); + } } diff --git a/core/src/test/java/kst4contest/utils/ApplicationFileUtilsTest.java b/core/src/test/java/kst4contest/utils/ApplicationFileUtilsTest.java new file mode 100644 index 00000000..8e2d2fbb --- /dev/null +++ b/core/src/test/java/kst4contest/utils/ApplicationFileUtilsTest.java @@ -0,0 +1,19 @@ +package kst4contest.utils; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.file.Path; + +import org.junit.jupiter.api.Test; + +class ApplicationFileUtilsTest { + + @Test + void debugFilesResolveBelowTheDedicatedApplicationDirectory() { + assertEquals( + Path.of(ApplicationFileUtils.getFilePath("praktiKST", "debug"), + "kst4contest-errors.log").toString(), + ApplicationFileUtils.getDebugFilePath( + "praktiKST", "kst4contest-errors.log")); + } +} diff --git a/core/src/test/java/kst4contest/view/map/MapCallsignRawSnapshotLabelTest.java b/core/src/test/java/kst4contest/view/map/MapCallsignRawSnapshotLabelTest.java new file mode 100644 index 00000000..d4126995 --- /dev/null +++ b/core/src/test/java/kst4contest/view/map/MapCallsignRawSnapshotLabelTest.java @@ -0,0 +1,37 @@ +package kst4contest.view.map; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class MapCallsignRawSnapshotLabelTest { + + @Test + void visibleMarkerLabelContainsOnlyTheCallsign() { + MapCallsignRawSnapshot snapshot = snapshot(); + + assertEquals("DM5M", snapshot.markerLabel()); + } + + @Test + void tooltipRetainsLocatorBandsUpgradeAndFrequencyDetails() { + String tooltip = snapshot().tooltipText(); + + assertTrue(tooltip.contains("DM5M")); + assertTrue(tooltip.contains("JO51AA")); + assertTrue(tooltip.contains("144, 432")); + assertTrue(tooltip.contains("B+")); + assertTrue(tooltip.contains("144: 144.310")); + } + + private MapCallsignRawSnapshot snapshot() { + return new MapCallsignRawSnapshot( + "DM5M", "DM5M", "JO51AA", 51.0, 10.0, + "144, 432", Map.of("144", "144.310"), true, + false, false, false, 100.0, 180.0, 0, 0L + ); + } +} diff --git a/core/src/test/java/kst4contest/view/map/MapMarkerClusterTooltipTest.java b/core/src/test/java/kst4contest/view/map/MapMarkerClusterTooltipTest.java index 859996e9..5274c10c 100644 --- a/core/src/test/java/kst4contest/view/map/MapMarkerClusterTooltipTest.java +++ b/core/src/test/java/kst4contest/view/map/MapMarkerClusterTooltipTest.java @@ -51,9 +51,10 @@ class MapMarkerClusterTooltipTest { List lines = List.of(MapMarkerClusterTooltip.build(clusterOf(3)).split("\\n")); assertEquals("3 stations", lines.get(0)); - assertEquals("DL000AA", lines.get(1)); - assertEquals("DL001AA", lines.get(2)); - assertEquals("DL002AA", lines.get(3)); + assertTrue(lines.get(1).startsWith("DL000AA")); + assertTrue(lines.get(2).startsWith("DL001AA")); + assertTrue(lines.get(3).startsWith("DL002AA")); + assertTrue(lines.get(1).contains("JO50JP")); } /** A hundred callsigns is a box taller than the window. */ diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 8ecd1481..f1eadb94 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -1,6 +1,6 @@ # KST4Contest Project Context -Last reviewed: 2026-10-03 +Last reviewed: 2026-10-23 This file is the durable technical project context for KST4Contest. It is not a user manual and not a replacement for the changelog. Current code, tests and authoritative external specifications remain the source of truth when this document is stale or ambiguous. @@ -53,15 +53,17 @@ mirror on the user-interface thread (FxRosterBinding -> ObservableList) `MessageBusManagementThread` must not directly iterate or mutate a UI-bound collection; it works on snapshots. UI-visible changes cross the controller/UI boundary through the dispatcher. -A roster hands out immutable snapshots and has no incremental change protocol: listeners receive the whole new content. `FxRosterBinding` mirrors a roster into the `ObservableList` a `TableView` holds, and must be disposed when its owner goes away — `shutdownRuntime()` releases all of them, which is what keeps a discarded runtime from surviving an operator profile switch. +A roster hands out immutable snapshots and has no incremental change protocol: listeners receive the whole new content. `RosterListBinding` mirrors or derives plain lists for the Compose states and must be disposed when its owner goes away — `shutdownRuntime()` releases all of them, which is what keeps a discarded runtime from surviving an operator profile switch. A batch of changes belongs in one `SimpleRoster.mutate(...)`, not one call per element: with several thousand ON4KST users the difference is not cosmetic. ## Configuration and Layout Persistence - Configuration and layout live in the `preferences.xml` of the **active operator profile**; see "Operator Profiles and Per-Profile Persistence" below. The current `preferences.xml` configuration version is 8. Version 8 adds the default-enabled `messageHandling_debugModeToFileEnabled` setting. Missing entries in older files retain the enabled default. Version 6 introduced optional managed leaf-column widths below `guiOptions`, identified by stable table and column IDs. Parent-column widths remain derived from their leaf columns. +- `guiOptions_darkModeActiveByDefault` is a functional, per-profile startup preference. Applying Settings updates it before the complete preference file is written. A temporary Design menu change affects only `guiOptions_darkModeActive` for the running session. Selective layout autosave reparses the current file and changes layout fields only, so it must not overwrite the startup default. - `GUIstationMapClusteringEnabled` is a layout preference below `guiOptions`. It defaults to `true`, is selectively autosaved and controls only screen-based clustering of nearby map markers. Missing or malformed values retain the enabled default for backward compatibility. -- Stored widths take precedence. Without a usable entry, a managed column is sized once when meaningful table data first becomes available. Message and similar free-text columns use a flexible initial width instead of following the longest value. +- `GUIstationMapAlwaysOnTop` is a map-only layout preference below `guiOptions`. It defaults to `false`, is applied through the existing AWT window API and is included in full and selective preference writes. Missing or malformed values retain normal window behaviour. +- Stored widths always take precedence, including widths below the calculated minimum. Without a usable entry, a managed column measures its heading and a bounded initial content sample once when the first data arrives; DPI, font scale and typography participate in the Compose measurement key. Automatic widths are capped, remain session-only and are not recalculated for every chat event. - Main-window and separate-monitor DXCluster/QSO tables use distinct layout IDs even though they share the underlying message stores. - Window sizes and positions, relevant divider positions and managed column widths are selectively autosaved after a short debounce. A pending write is flushed during application shutdown. - Window positions are persisted only as an **opt-in** (`guiOptions_rememberWindowPositions`, GUI tab, off by default; issue #59). Every secondary window and every `ComposeAlert`/profile-picker dialog opens centred on the screen the main window is on (`WindowPlacement`, fed by `WindowPlacementOnScreen`). A window the operator moved during the running session reopens there (shifted wholly into that screen's usable area), held in its `ComposeWindowHost` and tracked by `MoveTracker`; the remembered spot is dropped in favour of the main window's screen when its title bar cannot be reached on any connected screen. With the opt-in, operator moves (never window-manager nudges) are also stored per window id (the host's thread name) as `windowPosition` elements with the screen bounds they were made on, written by the layout autosave; a stored position is restored only when a screen with exactly those bounds is connected (`WindowPlacement.restore`), for the main window too. Order for secondary windows: session move, then stored position, then main window's screen. Opening sizes are clamped to the target screen's usable area without writing the clamped size back; an open window that rests on a screen too small for it is cut down (`WindowPlacement.fitted`), and enforced minimum sizes are capped to the smallest connected screen. Without a stored position the main window keeps the platform's placement; before it exists (update window, profile picker at startup) the platform decides as before. `GUIstationMapStagePositionXY` is an unused JavaFX leftover and must not be revived for this. @@ -129,6 +131,7 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen - UCXLog-compatible QSO packets and Win-Test `ADDQSO` packets are converted into one validated external-QSO state. Logger-specific numeric, metre and centimetre values and Win-Test band IDs are normalised once; the resolved band is then the sole source for per-band Worked and worked-grid state. - A missing or unknown logger band sets only the global Worked state. Worked-grid state requires both a recognised project band and a valid locator; no band or locator is inferred. Packets without a usable callsign are discarded without terminating the listener. - External logger threads do not read or mutate the user-list mirror. `ChatController` applies global and per-band Worked state to every active variant of the base callsign on the user-interface thread, through `UiDispatcher`, before evaluating a band-upgrade notice. +- Explicit **Mark as worked** context actions construct the same validated `ExternalLoggedQso` state and use the same controller/database path as logger `contactinfo` handling. The operation is additive and idempotent; it does not provide an Unwork action or create a parallel UI-only store. - The established Win-Test handling for 24, 47 and 76 GHz remains unchanged. Their Worked flags are retained, while only frequencies represented by the project `Band` model can create worked-grid state. ### Win-Test log recovery @@ -148,6 +151,7 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen - Cached entries are separated by owner identity through the primary key (`owner_callsign_raw` + `owner_locator6`). Earlier versions stored a single owner identity in a meta table and dropped the whole cache whenever the configured callsign or locator changed; with several operator profiles that would discard every computed profile on each switch. The old `TerrainProfileCache*` tables inside `praktiKST.db` are left in place and are still readable by older releases; the new file starts empty and refills itself. - The highest point of a remote locator square (path analysis, Station tab switch, on by default) is found by `LocatorHighestPointResolver`: a 5 x 5 grid of cell centres, one batched request against the same Open-Meteo provider and rate limiter as the profile, cached in memory per locator for the session and not persisted. A failed lookup is retried after five minutes; until then the analysis falls back to the square centre and that result is not kept in the reachability cache. The lookup runs on the reachability thread only; request building peeks the cache without blocking. The optional own ground height ASL replaces the first profile sample's elevation, a resolved highest point the last one. - `OfflineDemImportService` only prepares a local directory and copies manually selected Copernicus GLO-30 GeoTIFF files into it. Importing files does not activate an offline provider or change the active calculation chain. +- The station filter bar owns an operator-selected Reachability band override. `Auto` uses the shared `ReachabilityService` resolver; an explicit locally enabled band overrides it. Selecting a band, rendering or sorting the station table and applying filters may read cached results only. Online terrain work for this control remains behind the explicit **Calc selected** action for the current station. ### ON4KST session and authentication @@ -156,6 +160,7 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen - The primary category is part of the initial login. A distinct second category is added to the same session through ON4KST Single Sign-on; it must not create a second TCP connection or local login. - **Name in Chat** is a visible category-specific name field, not a login callsign or message destination. Private messages to the local station are addressed to the local login callsign. - The visible **Name in Chat** field, message context, QRG and beacon configuration remain category-specific. +- The Settings connection buttons observe the `On4KstConnectionState` mirrored in `MainWindowSurroundings` by `ConnectionStateFeed`. Connect is available only in `DISCONNECTED`; Disconnect is available while the Connection Manager reports an active attempt or session. `CONNECTING`, `STOPPING` and other intermediate states therefore cannot start a duplicate connection, while a completed disconnect or failed attempt immediately enables a new one. ### ON4KST session liveness @@ -168,12 +173,13 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen ### ON4KST replay history and diagnostic logging - `MessageHistoryRecorder` is the single non-JavaFX owner of raw ON4KST session-history file I/O. Reader and writer threads only report complete frames to it. -- The current program session is stored at `~/.praktiKST/Messagehistory.raw` as one `UTCRX|TXRAW_FRAME` event per line. Timestamps use UTC with milliseconds; TCP CR/LF terminators are not part of the raw frame. +- New diagnostic files live below `~/.praktiKST/debug/`. Existing files directly below `~/.praktiKST` are deliberately not migrated, renamed or removed. +- The current program session is stored at `~/.praktiKST/debug/Messagehistory.raw` as one `UTCRX|TXRAW_FRAME` event per line. Timestamps use UTC with milliseconds; TCP CR/LF terminators are not part of the raw frame. - RX is recorded immediately after `BufferedReader.readLine()`, before liveness handling or internal filtering. TX is recorded only after the final frame has been written and flushed successfully. - A non-empty current file is archived once when recording is first initialised for a new program session. Reconnects do not rotate it. Disabling closes the writer; re-enabling in the same program session appends to the same current file. - `LOGINC`/`LOGIN` password fields are replaced by `` through protocol-aware redaction. Other frames are not reconstructed or normalised for history output. - History I/O failures are warning-level diagnostics and must never terminate reader, writer or message-processing threads or deliberately disconnect ON4KST. -- The default-enabled Messagehandling setting also controls additional application `FINE`/`INFO` records in `kst4contest-errors.log`. `WARNING`/`SEVERE` records and exceptions remain file-logged regardless of that setting. File-log timestamps use UTC with milliseconds. +- The default-enabled Messagehandling setting also controls additional application `FINE`/`INFO` records in `debug/kst4contest-errors.log`. `WARNING`/`SEVERE` records and exceptions remain file-logged regardless of that setting. File-log timestamps use UTC with milliseconds. The active file rotates at 20 MiB and at most five files including the active file are retained. - The raw history is intended for future replay/simulation tooling and can contain personal chat and station data even though credentials are redacted. ## User Workflow / UI Invariants @@ -181,14 +187,28 @@ CR/LF framing, XML framing, ports/transports, callsign normalization and frequen - Contest operating speed and low-friction interaction are primary goals. - Incidental code changes must not unexpectedly change selection, focus, sorting, tab state, map zoom or prefilled text. - Map reset clears the selected target without changing zoom unless explicitly redesigned. -- **Group nearby stations** re-renders only the existing station-marker layer from JavaScript `stationData`. It must not reload the WebView, tiles or station data, request a new controller snapshot, or change zoom, viewport or selection. +- **Grouping** changes only spatial clustering of the existing Compose map markers. It must not reload tiles or station data, request a new controller snapshot, or change zoom, viewport or selection. - Base-callsign aggregation into one geographical marker happens before screen-based clustering. Disabling clustering displays each resulting positionable map station individually but never splits active variants of the same normalised base callsign into separate geographical markers. - A hidden Station Map is not continuously refreshed. Relevant background changes mark it dirty; the next `showWindow()` performs one current full refresh before normal visible-window updates resume. - Maidenhead grid construction depends only on viewport bounds, zoom and WebView size. Ordinary station-data refreshes must not rebuild or serialise the grid when those inputs are unchanged. - Panning updates viewport and grid state through `notifyViewport()` without rebuilding station markers or screen-based clusters. Zoom changes, station-data changes and clustering toggles still rebuild the marker layer. - Debug file logging reports Station Map refreshes only when total FX-thread refresh time reaches 100 ms. The diagnostic separates snapshot and render time and includes visible-member and station counts. - Station selection preserves the established `/cq callsign` prefill behaviour. +- `StationSelectionState` is the canonical selection shared by the station table and Further Info panel. Its identity is the complete callsign plus chat category. Explicit actions carry a `StationSelectionSource` and increment a separate scroll-request revision even when the same station is selected again; roster reconciliation replaces the selected instance without moving the table. +- Recent-join state is transient domain state keyed by complete callsign plus chat category. Only live `UA5`/`UA2` frames can create an entry; the `UA0`/`UE` initial or reconnect snapshot and `UM3` profile updates never do. Duplicate live updates do not renew the timestamp, a leave removes the entry, and a genuine rejoin begins a new ten-minute interval. The local login callsign is excluded. The core `ObservableRoster` is mirrored through `UiDispatcher`; New-joined actions reuse the canonical station selection and scroll request. +- The station table starts with a case-insensitive ascending callsign sort. A user-selected sort remains authoritative across roster and filter updates. An externally selected filtered station is pinned into the table only; `filteredRows` remains the exact filter result used by the map. +- PM, Public, QSO-of-the-other, priority, timeline and individual map-marker actions route through the application-level station selector. Directed messages target the sender, except that an outgoing local PM targets its receiver. Public and QSO-of-the-other rows target their sender in the message category. Direct clicks and context clicks inside the station table select without issuing an automatic scroll request; selections originating in the map, messages, priority, New joined or other station controls issue one even when the identity was already selected. +- The selected-station message radio mode is independent of station identity and always retains the complete callsign plus chat category. `PM_TO_ME` is the private conversation with the local station plus public traffic from the selected station carrying the structured leading `(LOCALCALL)` address, `PM_TO_OTHER` is private traffic sent by the selected station to other participants, `PUBLIC` is traffic sent by it to `ALL`, and `NOTHING` applies no additional type/receiver restriction. An arbitrary callsign mention in prose is not a structured public address. +- ON4KST chat parsing records `messageDirectedToCommunity` from the receiver field and `messageDirectedToMe` from either a genuine private receiver or the exact leading public address. The selected-station Compose message mirror is initialized with the main window state, before the first selection callback, and roster changes recompute and push the filtered rows automatically. +- The map's **Reset filters** action uses the same `StationFilterState.reset()` path as the main filter bar and does not change sort, selection, zoom, viewport or the display-only Grid-color toggle. Individual raw-callsign markers retain an already selected category variant; otherwise they use the configured Main category. Spatial clusters zoom and never choose an arbitrary member. +- The user list and map share one station context-menu implementation. A secondary click selects a unique target through `StationSelectionState`; base-call markers or screen clusters containing several full-callsign/category identities first require an explicit target choice. Message snippets reuse the existing send-field action, and the context sked editor owns an isolated draft with the target fixed at opening time. +- Map markers draw only the display callsign; bands, `B+`, locator and QRG details remain in tooltips. When Grid color is enabled, the canvas draws a black outline and a 30 percent black fill overlay for four-character squares from `WorkedGrossFieldCache.snapshotWorkedGrossFieldsAny()`, independently of visible station markers. The cache is rebuilt from SQLite at startup and is the same any-band state used by **Only new grids**. +- The AP timeline renders the producer's four candidate lanes in order (two in-beam, two global priority) plus a separate sked lane. Its relative 0–30 minute axis normally uses five-minute ticks and increases the interval when labels would overlap. The vertical message splitter applies pane-specific minimums for the timeline, shortcut row and send row, so older stored divider positions cannot clip these fixed-height controls; stored positions remain the unconstrained baseline. - Sending without an explicitly selected send category preserves the established Main-category fallback unless explicitly changed. +- The lower selected-station panel contains a vertical two-pane `SplitterPane`: the upper pane owns the independently scrollable message table, while the lower pane scrolls NOT-QRV and Sked above a pinned lookup group. The lower pane reserves 160 dp before stored divider positions are applied, so normal small-window layouts retain direct access to lookup actions; impossible smaller layouts keep the action groups scrollable. `GUIselectedCallSignSplitPane_dividerposition` persists this divider through the existing profile-scoped layout autosave. Station or message updates never reset it. Empty `DataTableView` instances retain their header and border and show an explicit empty-state label. Header metadata and message filters use width-aware flow layouts; the NOT-QRV and sked groups switch between a row and a column from the panel's actual Compose constraint, without duplicating their domain state. +- The selected-station sked picker takes its options exclusively from `MainWindowState.activeBands`, which originates in `BandOpportunityResolver.getEnabledStationBands(...)`. Station QRG and name evidence chooses the initial band but must not empty or narrow the operator's locally enabled choices. The detected-band header value is already localized and labelled by its producer, so the Compose panel renders it unchanged. +- Compact desktop buttons, text fields, choices and checkbox rows share `Density.CONTROL_HEIGHT` at 26 dp and `compactControlTextStyle()`. Material touch-target and component defaults must not reintroduce different visible heights. Compass filter buttons keep a common 28 dp width with reduced horizontal content padding; the send-row QRG fields reserve 96 dp and QTF fields 56 dp so normal contest values remain visible. The station filter bar groups related controls on shared theme surfaces while its outer flow rows continue to wrap from the station pane's actual width. +- `actualQTF` is initialised once from the active profile's `stn_qtfDefault` after preferences are read. Later PSTRotator feedback or manual runtime changes replace that value without changing the stored default, and a repeated preferences read must not overwrite a live heading. Manual QTF-heading transfer is enabled by default and persisted as the profile-scoped opt-out marker `stn_qtfFilterDirectionAsAntennaHeadingOptedOut`; absence of that marker migrates root and operator profiles to enabled, while subsequent writes preserve an explicit opt-out. The effective state is `!stn_pstRotatorEnabled && !optedOut`: PSTRotator 1 temporarily suspends transfer without erasing the user's choice, and PSTRotator 2 remains independent. Explicit QTF field entries and compass-button actions route directly to `actualQTF`, even when the QTF predicate is off; filter reset, checkbox changes and restored filter state never take that route. The coupling is one-way and never calls the rotor command path. ## Autoanswer / Beacon @@ -393,7 +413,7 @@ Before implementing planned items, re-check current decisions and obtain a fresh - Historical project context is useful but may be stale; current code/tests win. - External service/API behaviour must be verified against current upstream documentation when uncertain. - Screenshots in manuals/website may need targeted replacement after visible UI changes; never fabricate them. -- `station_map_path_analysis.png` and `station_map_reset.png` predate the **Group nearby stations** checkbox in the map header. Replace them with current screenshots when suitable source images are available; the website reuses `station_map_path_analysis.png` through `/manual/assets/`. +- `station_map_path_analysis.png` and `station_map_reset.png` predate the current header controls, callsign-only markers, shared station context menu and Worked-square overlay. Replace them with current screenshots when suitable source images are available; the website reuses `station_map_path_analysis.png` through `/manual/assets/`. ## Recent Significant Changes diff --git a/github_docs/de-Benutzeroberflaeche.md b/github_docs/de-Benutzeroberflaeche.md index b9b1693d..1d15ecf5 100644 --- a/github_docs/de-Benutzeroberflaeche.md +++ b/github_docs/de-Benutzeroberflaeche.md @@ -102,10 +102,14 @@ Jede Statuszelle besitzt einen Tooltip mit der Legende und dem für die betreffe ![Bandbezogener Worked-Status und Worked-Großfelder](worked_band_status.png) -**Sortierung**: Klick auf Spaltenköpfe. QRB-Sortierung arbeitet numerisch (ab v1.22 korrigiert). +Beim ersten Aufbau ist die Benutzerliste alphabetisch nach dem vollständigen Rufzeichen sortiert, ohne Groß- und Kleinschreibung zu unterscheiden. Neue Stationen werden an der passenden Stelle eingefügt. Eine anschließend über einen Spaltenkopf gewählte Sortierung bleibt bei neuen Stationen, Daten- und Filteraktualisierungen erhalten. QRB wird numerisch sortiert. +Die Stationsauswahl gilt gemeinsam für Benutzerliste, PM-, Public- und QSO-of-the-other-Tabelle, Prioritätsanzeige, AP-Timeline und Karte. Eine Auswahl aus einer anderen Ansicht scrollt die Benutzerliste zur aktuellen Position des Chatmembers und hebt die Zeile hervor. Ist die Station durch einen Filter ausgeblendet, erscheint sie vorübergehend zusätzlich in der Benutzerliste; die gespeicherten Filter und die exakt gefilterte Kartenansicht ändern sich dadurch nicht. Rufzeichen und Chat-Kategorie bilden gemeinsam die Identität. + +Ein fett dargestelltes Rufzeichen kennzeichnet eine Station, die während der laufenden Sitzung tatsächlich neu in den Chat gekommen ist. Die Hervorhebung bleibt zehn Minuten sichtbar. Die anfängliche Benutzerliste und eine vollständige Neusynchronisierung nach einem Reconnect erzeugen keine solchen Markierungen. Im Day-Design wird eine kontrastreiche dunkle, im Evening-Design eine entsprechend helle Schrift verwendet. Ein grün und fett dargestelltes Rufzeichen kennzeichnet eine aus einer gerichteten Nachricht hergeleitete Richtungsgelegenheit. Die Markierung bezieht sich auf den Absender der Nachricht und bleibt höchstens fünf Minuten sichtbar. Herleitung und Grenzen: [Richtungsgelegenheiten aus gerichteten Nachrichten](de-Funktionen#richtungsgelegenheiten-aus-gerichteten-nachrichten). + ### Sendfeld Das Sendfeld enthält den vorbereiteten Text für die nächste ausgehende Nachricht. @@ -178,8 +182,12 @@ Damit muss der Divider nicht allein deshalb verschoben werden, um eine einzelne Die Filterleiste befindet sich oberhalb der Chatmember-Tabelle. Filter können miteinander kombiniert werden; eine Station bleibt nur sichtbar, wenn sie alle aktiven Bedingungen erfüllt. +Dezent umrahmte Flächen fassen **QRB**, **QTF** einschließlich Richtungstasten, **Hide**, **Show** und **Reachability** jeweils funktional zusammen. **Find** und **Reset filters** bleiben eigenständige Controls. Die Gruppen ändern nur die Darstellung; Filterzustände, Reset-Verhalten und Kartenkopplung bleiben gemeinsam verwaltet. + ![Umgebrochene Filterleiste bei schmaler Chatmember-Ansicht](filter_bar_wrapped.png) +> **Hinweis:** Dieser Screenshot zeigt noch die frühere Anordnung der Filterleiste. Er wird nach der visuellen Prüfung der neuen kompakten, umbrechenden Leiste ersetzt. + ### Stationsfilter | Bedienelement | Wirkung | @@ -196,6 +204,8 @@ Die Filterleiste befindet sich oberhalb der Chatmember-Tabelle. Filter können m | **Tropo >=0dB** | Zeigt Stationen mit einer berechneten, nicht negativen SSB-Marge | | **AS next 5m** | Zeigt Stationen mit einem aktuellen oder innerhalb der nächsten fünf Minuten erwarteten AirScout-Fenster | +Standardmäßig aktualisiert eine gültige direkte Eingabe in der QTF-Gruppe oder der Klick auf eine Richtungstaste zugleich die aktuelle eigene Antennenrichtung. Diese ausdrückliche Übernahme funktioniert unabhängig davon, ob **Show only QTF** ein- oder ausgeschaltet ist. Das Zurücksetzen oder bloße Ausschalten des Filters verändert die Antennenrichtung nicht. Im Abschnitt **Station → Manuelle Antennenrichtung** kann das Verhalten mit **QTF-Filterrichtung als eigene Antennenrichtung übernehmen** profilbezogen ausgeschaltet werden. PSTRotator 1 hat vorübergehend Vorrang, ohne diese gespeicherte Auswahl zu verändern. + Bei **New bands** werden aktuelle QRGs, Bandangaben im Namensfeld und aktive Rufzeichenvarianten gemeinsam ausgewertet. Manuelle NOT-QRV-Markierungen haben Vorrang. Der Filter **Tropo >=0dB** entfernt nur Stationen, für die eine abgeschlossene Berechnung eine negative Marge ergeben hat. Noch nicht berechnete oder fehlgeschlagene Auswertungen bleiben sichtbar. Andernfalls würde ein fehlender API-Wert wie ein nachgewiesen ungeeigneter Funkweg behandelt. @@ -252,21 +262,31 @@ Im Klartext: Die Filter bestimmen den Tabelleninhalt, aber nicht mehr die Mindes Rechts unten werden die Nachrichten der ausgewählten Station zusammengeführt. Dazu gehören öffentliche Nachrichten, Privatnachrichten an die eigene Station und – soweit im Chat sichtbar – Privatnachrichten an andere Stationen. -Der im Panel gewählte Filter bestimmt, welche dieser Nachrichten angezeigt werden. Unter **Settings → GUI** lässt sich festlegen, welcher Filter beim Öffnen einer Stationsinformation vorausgewählt ist: +Der im Panel gewählte Filter bestimmt als eigene Dimension, welche Nachrichtenart für die ausgewählte Station angezeigt wird. Unter **Settings → GUI** lässt sich der Startwert festlegen: -- alle Nachrichten, -- Privatnachrichten an die eigene Station, -- Privatnachrichten an andere Stationen oder -- öffentliche Nachrichten. +- **pm to me**: private Nachrichten zwischen der ausgewählten und der eigenen Station sowie öffentliche Nachrichten der ausgewählten Station, deren strukturierte Adressangabe am Anfang das eigene Login-Rufzeichen nennt, +- **pm to other**: private Nachrichten der ausgewählten Station an andere Teilnehmer, +- **Public msgs**: öffentliche Nachrichten der ausgewählten Station an `ALL`, +- **nothing**: alle Nachrichten mit Beteiligung der ausgewählten Station in ihrer Chat-Kategorie, ohne zusätzliche Einschränkung nach Nachrichtenart oder Empfänger. + +Eine bloße Erwähnung des eigenen Rufzeichens irgendwo im Nachrichtentext macht eine öffentliche Zeile nicht zu **pm to me**. Dafür muss die ON4KST-Nachricht mit der ausdrücklichen Adressangabe in Klammern beginnen. Die Einstellung verändert nur die Darstellung im Stationsinfo-Panel. Nachrichten werden dadurch weder verworfen noch aus den übrigen Nachrichtentabellen entfernt. Der Filter kann im Panel jederzeit für die aktuell betrachtete Station gewechselt werden. -Im unteren Bereich können für die ausgewählte Station bandbezogene **Not QRV**-Markierungen gesetzt werden. Sichtbar sind die Bänder, die in den Stationseinstellungen für die eigene Station aktiviert wurden. **tag not qrv all** setzt beziehungsweise entfernt die Markierung für alle unterstützten Bänder gemeinsam, einschließlich momentan nicht eingeblendeter Bänder. +Ein horizontaler Divider im Further-Info-Bereich teilt die Höhe zwischen Nachrichtentabelle und Stationsfunktionen. Die Nachrichtentabelle wird beim Verschieben unmittelbar höher oder niedriger. NOT-QRV und Sked besitzen im unteren Bereich einen eigenen vertikalen Scrollbereich; **Look up and show** bleibt darunter fest sichtbar. Die Dividerposition wird mit dem Layout des aktiven Root- oder Operatorprofils gespeichert und bei einem kleineren Fenster auf einen bedienbaren Wert begrenzt. Leere Tabellen zeigen weiterhin ihre Spaltenstruktur und den Hinweis **Keine Einträge**. + +Der Stationskopf fasst Rufzeichen, Chat-Kategorie, QTF, QRB, letzte Aktivität und erkannte Bänder platzsparend zusammen. Der Locator wird im Kopf nicht noch einmal wiederholt; in den übrigen Stationsansichten bleibt er unverändert verfügbar. Bei einem schmalen Panel fließen die Angaben kontrolliert in weitere Zeilen. **Show messages** steht direkt vor den vier Nachrichtenfiltern und fließt gemeinsam mit ihnen um. + +Die Bereiche **NOT QRV** und **Sked alert** stehen nebeneinander, solange die tatsächliche Breite des rechten Panels dafür ausreicht. Wird der Splitter weiter nach rechts verschoben, ordnet KST4Contest die beiden umrahmten Gruppen untereinander an. Die enthaltenen Controls brechen innerhalb ihrer Gruppe ebenfalls um; ein horizontaler Scrollbereich ist dafür nicht nötig. + +Im unteren Bereich können für die ausgewählte Station bandbezogene **Not QRV**-Markierungen gesetzt werden. Sichtbar sind kompakte Togglebuttons für die Bänder, die in den Stationseinstellungen für die eigene Station aktiviert wurden. **all** setzt beziehungsweise entfernt die Markierung für alle unterstützten Bänder gemeinsam, einschließlich momentan nicht eingeblendeter Bänder. Die Änderung wirkt sofort auf die Spalte **NOT QRV @**, die Bandmöglichkeiten und die zugehörigen Filter. Sie wird in der internen Datenbank gespeichert und nach einem Neustart wiederhergestellt. ![Bandbezogene NOT-QRV-Markierungen im Further-Info-Bereich](not_qrv_controls.png) +> **Screenshot-Hinweis:** Dieses Bild zeigt noch die frühere Checkbox-Darstellung. Es wird nach der manuellen Abnahme der kompakten Togglebuttons ersetzt. + Im selben Bereich wird der aktuelle **Priority score** der ausgewählten Station angezeigt. Mit **Sked fail** lässt sich ein fehlgeschlagener Versuch markieren. Der Score des normalisierten Basisrufzeichens wird dadurch stark reduziert. **Reset fail** entfernt diese Markierung wieder. Die Markierung gilt für alle aktiven Suffix- und Kategorievarianten der Station und bleibt innerhalb der laufenden Programmsitzung erhalten. @@ -284,6 +304,8 @@ Darunter befinden sich die Bedienelemente zum Anlegen eines Skeds: ![Sked-Steuerung im Further-Info-Bereich](sked_controls.png) +> **Screenshot-Hinweis:** Dieses Bild zeigt noch die frühere getrennte Anordnung. Es wird nach der manuellen Abnahme der responsiven Bereiche **NOT QRV** und **Sked alert** ersetzt. + Das vorgeschlagene Band wird aus aktuellen QRG- und Namensinformationen der Station hergeleitet. Vor dem Anlegen kann es ausdrücklich geändert werden. Die Mode-Auswahl betrifft nur die Übergabe an Win-Test; der interne Sked und die Reminder-PMs funktionieren unabhängig davon. **Create sked** legt den Termin immer zuerst in KST4Contest an. Ist der Win-Test-Netzwerk-Listener aktiv, wird anschließend zusätzlich eine Übergabe an Win-Test versucht. Kann keine zum ausgewählten Band passende QRG ermittelt werden oder ist Win-Test nicht erreichbar, bleiben der interne Sked, seine Priorisierung und gegebenenfalls angelegte Reminder erhalten. @@ -294,7 +316,13 @@ Die vollständige Herleitung und die Grenzen der Funktion sind unter [Skeds und ## Prioritätsliste -Die kompakte Prioritätsleiste befindet sich rechts zwischen Benutzerliste und Further-Info-Bereich. Sie zeigt die beiden derzeit höchstbewerteten Kandidaten unmittelbar im Hauptfenster: +Die kompakte Leiste befindet sich rechts zwischen Benutzerliste und Further-Info-Bereich. Links erscheint bei Bedarf die Gruppe **Neu hinzugekommen**, rechts bleibt die unabhängige Gruppe **Priority**. + +**Neu hinzugekommen** zeigt die neuesten tatsächlichen Chat-Beitritte für zehn Minuten an, neuere Einträge zuerst. Bei ausreichender Breite sind bis zu fünf Stationen direkt sichtbar; weitere oder wegen geringer Breite ausgeblendete Einträge stehen im kompakten **more**-Dropdown. Die anfängliche Benutzerliste, eine vollständige Neusynchronisierung, reine Profilaktualisierungen und das eigene Rufzeichen werden nicht aufgenommen. Verlässt eine Station den Chat, verschwindet ihr Eintrag sofort. Ein späterer echter Wiedereintritt startet die Frist neu. Vollständiges Rufzeichen und Chat-Kategorie bleiben getrennte Identitäten. + +Ein Klick auf einen Eintrag verwendet die gemeinsame Stationsauswahl, aktualisiert das Further-Info-Panel und scrollt bei Bedarf zur Station in der Benutzerliste. + +Die Gruppe **Priority** zeigt die beiden derzeit höchstbewerteten Kandidaten unmittelbar im Hauptfenster: ```text Priority: 1 RUFZEICHEN SCORE 2 RUFZEICHEN SCORE more @@ -306,6 +334,8 @@ Die Schaltfläche **more** öffnet ein separates Fenster mit bis zu 15 Kandidate ![Priority Score, kompakte Kandidatenliste und Further-Info-Steuerung](priority_score_overview.png) +> **Hinweis:** Dieser Screenshot zeigt noch die frühere Geometrie des Further-Info-Bereichs. Er wird nach der visuellen Prüfung des neuen scrollbar begrenzten Stationsbereichs ersetzt. + Stationen mit einem Score von `0` werden nicht in die Prioritätsliste aufgenommen. In der Benutzerliste bleiben sie sichtbar, sodass der Ausschluss nachvollzogen und beispielsweise durch eine geänderte NOT-QRV-Markierung korrigiert werden kann. Der Score wird für das normalisierte Basisrufzeichen berechnet. Mehrere aktive Varianten wie `9A0BB-2` und `9A0BB-70` können daher in der Benutzerliste denselben Wert anzeigen. Die Chatmember bleiben trotzdem getrennte Nachrichtenziele. @@ -316,6 +346,14 @@ Herleitung und Grenzen: [Prioritätsscore und Prioritätsliste](de-Funktionen#pr --- +## AP-Timeline + +Die AP-Timeline zeigt Chancen im kommenden Zeitfenster relativ zu jetzt. Die zurückhaltende Minutenachse läuft von `0 min` links bis `+30 min` rechts. Normalerweise erscheinen Marken in Fünf-Minuten-Schritten; bei wenig horizontalem Platz wird der Abstand vergrößert, damit die Beschriftungen lesbar bleiben. + +Die vier Kandidatenzeilen entsprechen direkt der vorhandenen Prioritätsauswahl: **Im Strahl 1**, **Im Strahl 2**, **Priorität 1** und **Priorität 2**. **Skeds** besitzen eine eigene Zeile. Die bisherige Farbcodierung, Antennenausrichtung und Auswahl einer Station per Klick bleiben erhalten. + +--- + ## Stationskarte Die Stationskarte kann auf zwei Wegen geöffnet werden: @@ -327,8 +365,12 @@ Die Karte verwendet die Stationen, die nach Anwendung der aktuellen Benutzerlist ![Stationskarte mit ausgewählter Station und eingeblendeter Streckenanalyse](station_map_path_analysis.png) +> **Screenshot-Hinweis:** Das Bild zeigt noch die frühere Markerbeschriftung und weder das neue Kontextmenü noch die Worked-Großfeldfläche. Es wird nach der manuellen Kartenabnahme ersetzt. + ### Station auswählen +Ein Marker zeigt als sichtbare Beschriftung ausschließlich das Rufzeichen. Locator, erkannte Bänder, `B+`-Status und zuletzt bekannte QRGs stehen weiterhin im Tooltip. + Ein einzelner Stationsmarker kann direkt angeklickt werden. KST4Contest: 1. übernimmt den konkreten Chatmember als aktuelle Auswahl, @@ -336,12 +378,18 @@ Ein einzelner Stationsmarker kann direkt angeklickt werden. KST4Contest: 3. aktualisiert den **Further Info**-Bereich und 4. bereitet das vollständige sichtbare Rufzeichen als `/cq`-Empfänger vor. -Ist **Group nearby stations** ausgewählt, werden Marker, die bei niedrigen Zoomstufen zu dicht beieinanderliegen, als Cluster mit einer Stationsanzahl dargestellt. Ein Klick auf einen Cluster vergrößert den betreffenden Kartenausschnitt. Erst ein anschließend sichtbarer einzelner Marker wählt eine konkrete Station aus. Wird die Checkbox ausgeschaltet, zeigt die Karte unabhängig von der Zoomstufe alle positionierbaren Stationen als einzelne Marker. +Ist **Gruppierung** ausgewählt, werden Marker, die bei niedrigen Zoomstufen zu dicht beieinanderliegen, als Cluster mit einer Stationsanzahl dargestellt. Ein Klick auf einen Cluster vergrößert den betreffenden Kartenausschnitt. Erst ein anschließend sichtbarer einzelner Marker wählt eine konkrete Station aus. Wird die Checkbox ausgeschaltet, zeigt die Karte unabhängig von der Zoomstufe alle positionierbaren Stationen als einzelne Marker. Der Tooltip erläutert den vollständigen Zweck des kompakten Schalters. -Das Umschalten wirkt sofort und verändert weder Zoom noch Kartenausschnitt oder Stationsauswahl. Die Einstellung wird automatisch gespeichert und beim nächsten Programmstart wiederhergestellt. Ohne gespeicherte Einstellung bleibt **Group nearby stations** ausgewählt, damit bestehende Installationen zunächst das bisherige Verhalten behalten. +Das Umschalten wirkt sofort und verändert weder Zoom noch Kartenausschnitt oder Stationsauswahl. Die Einstellung wird automatisch gespeichert und beim nächsten Programmstart wiederhergestellt. Ohne gespeicherte Einstellung bleibt **Gruppierung** ausgewählt, damit bestehende Installationen zunächst das bisherige Verhalten behalten. Der Schalter betrifft nur die räumlichen Cluster auf dem Bildschirm. Aktive Chatvarianten desselben normalisierten Basisrufzeichens können weiterhin einen gemeinsamen geografischen Marker verwenden und bleiben unabhängig davon getrennte Nachrichtenziele. +Ein Rechtsklick auf einen Marker oder eine Zeile der Benutzerliste öffnet dasselbe Stations-Kontextmenü. Es enthält **Message snippets**, **Create sked…**, bandbezogene **Tag NOT QRV**- und **Mark as worked**-Aktionen sowie **Calculate reachability**. Repräsentiert ein Marker mehrere vollständige Rufzeichen oder Chat-Kategorien, muss zuerst das konkrete Ziel gewählt werden. Dadurch werden keine Aktionen willkürlich auf den ersten Eintrag angewendet. + +Die Snippet-Aktion ergänzt lediglich das Sendefeld und sendet nicht selbst. Der Sked-Dialog arbeitet mit dem beim Öffnen festgehaltenen Ziel; ein späterer Auswahlwechsel ändert diesen Entwurf nicht. **Mark as worked** verwendet dieselbe additive Worked- und Datenbanklogik wie ein empfangenes Logger-`contactinfo` und entfernt keine bereits vorhandene Markierung. + +**Calculate reachability** startet die vorhandene Streckenberechnung ausdrücklich für das gewählte Band. Wird die Aktion aus der Benutzerliste aufgerufen, öffnet KST4Contest die Karte und zeigt dort Ladezustand, Ergebnis oder Fehler für genau dieses Ziel an. + Die Kopfzeile ergänzt bei ausgewählter Station: - vollständiges Rufzeichen, @@ -353,6 +401,10 @@ Die Kopfzeile ergänzt bei ausgewählter Station: Lange Inhalte werden in der Kopfzeile gekürzt. Der vollständige Text steht im Tooltip. +**Filter zurücksetzen** führt dieselbe Aktion wie die Schaltfläche in der Benutzerliste aus. Dabei werden alle aktivierten Stationsfilter, die Rufzeichensuche und Richtungsfilter zurückgesetzt. Die reine Darstellungsoption **Grid color** bleibt ebenso unverändert wie Sortierung, Stationsauswahl, Zoom und Kartenausschnitt. **Immer im Vordergrund** wirkt ausschließlich auf das Kartenfenster, wird sofort angewendet und für dieses Operatorprofil automatisch gespeichert; ohne gespeicherten Wert ist die Option ausgeschaltet. + +Bei aktivem **Grid color** kennzeichnet die Karte dauerhaft bekannte, bereits gearbeitete vierstellige Maidenhead-Großfelder mit einem schwarzen Rahmen und einer schwarzen Flächenüberblendung von 30 Prozent. Die Darstellung stammt aus derselben persistenten Worked-Grid-Verwaltung wie **Only new grids** und bleibt deshalb auch sichtbar, wenn sich momentan keine Chatstation in dem Großfeld befindet. Bei ausgeschaltetem **Grid color** werden weder Rahmen noch Flächenüberblendung gezeichnet. + ### Auswahl mit Reset view löschen **Reset view** löscht die Stationsauswahl, ohne die Kartenposition oder den Zoomlevel zu verändern. @@ -429,6 +481,8 @@ Der untere Bereich des Hauptfensters enthält drei globale Nachrichtentabs. Ihr | **DXCluster messages** | Über ON4KST empfangene DX-Cluster-Meldungen | | **QSO of the other** | Gerichtete Nachrichten zwischen zwei anderen Stationen | +Ein Klick auf eine Zeile unter **Public messages** oder **QSO of the other** wählt den Absender als aktuelle Station aus und scrollt die Benutzerliste zu dessen Rufzeichen und Chat-Kategorie. Der Inhalt der globalen Tabs bleibt dabei unverändert. + ![Globale Nachrichtentabs im Hauptfenster](global_message_tabs.png) Im Tab **QSO of the other** werden Absender und Empfänger getrennt dargestellt. Die Spalten **Last QRG TX** und **Last QRG RX** enthalten die zuletzt für beide Stationen bekannten Frequenzen. Sie geben nicht zwingend die QRG der angezeigten Unterhaltung wieder. @@ -494,6 +548,8 @@ Die serverbezogenen Funktionen sind nur bei vollständig aufgebauter ON4KST-Verb KST4Contest speichert Größen und Positionen der Programmfenster, die relevanten Divider sowie manuell geänderte Tabellenbreiten automatisch nach einer kurzen Verzögerung in der Konfigurationsdatei. Ein ausstehender Layoutstand wird beim Programmende noch geschrieben. **Save Settings** ist dafür nicht erforderlich, speichert aber weiterhin den vollständigen aktuellen Stand einschließlich Layout. +Dazu gehört der horizontale Divider zwischen der Nachrichtentabelle und den Stationsfunktionen im Further-Info-Bereich. Sein Wert gilt für das aktive Root- oder Operatorprofil; beim Wiederherstellen begrenzt KST4Contest ihn auf die im aktuellen Fenster nutzbare Höhe. + Das Hauptfenster wird beim Start zusätzlich gegen den sichtbaren Bereich des primären Bildschirms geprüft. Ist die gespeicherte Größe zu groß, verkleinert und verschiebt KST4Contest das Fenster so, dass es wieder erreichbar bleibt. Die genaue Herleitung ist unter [Bildschirmgerechte Größe des Hauptfensters](de-Funktionen#bildschirmgerechte-größe-des-hauptfensters-ab-v141) beschrieben. Für die übrigen Programmfenster gilt diese zusätzliche Größenbegrenzung derzeit nicht. Wird beispielsweise das separate Monitorfenster nach einem Wechsel auf einen kleineren Bildschirm zu groß dargestellt, genügt eine manuelle Korrektur; die neue Größe wird automatisch gespeichert. diff --git a/github_docs/de-Changelog.md b/github_docs/de-Changelog.md index b0b8143b..c23f7a91 100644 --- a/github_docs/de-Changelog.md +++ b/github_docs/de-Changelog.md @@ -56,6 +56,14 @@ Mehrere Operateure an einem Rechner können jetzt eigene Rufzeichen, Locators un ### Behoben +- **Verbindung aus den Einstellungen erneut aufbauen:** Nach **Disconnect** blieb **Connect to ...** im geöffneten Einstellungsfenster deaktiviert. Die Buttons folgen jetzt dem Verbindungszustand des Connection Managers, sperren doppelte Verbindungsversuche und werden nach einem vollständigen Disconnect oder einem fehlgeschlagenen Versuch wieder freigegeben. + +- **Reachability-Bedienung wieder sichtbar:** Die Compose-Filterleiste enthält wieder **Reachability** mit **Auto** und den lokal aktivierten Bändern sowie **Calc selected**. Nur die ausdrückliche Berechnung der ausgewählten Station darf Online-Höhendaten anfordern; Tabellenaufbau, Sortierung und Bandauswahl verwenden ausschließlich vorhandene Ergebnisse. + +- **Kompaktere Oberfläche auf kleinen Bildschirmen:** Der Further-Info-Bereich ist scrollbar, leere Tabellen zeigen einen eindeutigen Leerzustand, Filterzeilen brechen bei Bedarf um und lange Sendetexte lassen sich vollständig als Hinweis anzeigen. Bedienelemente und rechte Fensterbereiche sind deutlicher voneinander abgegrenzt. + +- **Diagnosedateien gebündelt und begrenzt:** Neue Fehlerprotokolle und ON4KST-Messagehistory-Dateien werden unter `.praktiKST/debug` angelegt. Vorhandene Dateien im bisherigen Verzeichnis bleiben unverändert. Das Fehlerprotokoll rotiert bei 20 MiB und behält einschließlich der aktiven Datei höchstens fünf Generationen. + - **Einheitliche Schriftgröße:** Unter macOS und Linux verwendet JavaFX eine etwas größere Standardschrift als unter Windows. Dadurch wurden Beschriftungen in fest bemessenen Bedienelementen abgeschnitten. KST4Contest verwendet jetzt auf allen Systemen dieselbe Grundschriftgröße; unter Windows ändert sich nichts. - **Dark Mode nach dem Neustart:** Das Hauptfenster startete immer hell, auch wenn zuletzt der Dark Mode aktiv war, während die Stationskarte dunkel blieb. Jetzt starten alle Fenster einheitlich mit dem Farbschema des Profils. diff --git a/github_docs/de-Home.md b/github_docs/de-Home.md index 43bd39cb..ac62ba66 100644 --- a/github_docs/de-Home.md +++ b/github_docs/de-Home.md @@ -102,12 +102,12 @@ Die Fehler-Logdatei wird hier gespeichert: | Betriebssystem | Pfad | |---|---| -| Linux / macOS | `~/.praktiKST/kst4contest-errors.log` | -| Windows | `C:\Users\\.praktiKST\kst4contest-errors.log` | +| Linux / macOS | `~/.praktiKST/debug/kst4contest-errors.log` | +| Windows | `C:\Users\\.praktiKST\debug\kst4contest-errors.log` | Vor dem Hochladen sollte die Datei kurz geprüft werden. Ein Fehlerprotokoll enthält überwiegend technische Informationen, kann abhängig vom Fehler aber beispielsweise lokale Dateipfade oder weitere Kontextdaten enthalten. -Ist unter **Settings → Messagehandling → Enable debug mode to file** der Debug-Modus aktiviert, enthält das gleiche Verzeichnis zusätzlich `Messagehistory.raw` und gegebenenfalls archivierte `Messagehistory-*.raw`. Diese Dateien zeichnen den vollständigen ON4KST-Sessionverkehr mit RX und TX auf. Loginpasswörter werden redigiert, Chat- und Stationsdaten bleiben jedoch enthalten. Eine solche Datei deshalb nur nach bewusster Prüfung weitergeben. +Ist unter **Settings → Messagehandling → Enable debug mode to file** der Debug-Modus aktiviert, enthält das Verzeichnis `debug` zusätzlich `Messagehistory.raw` und gegebenenfalls archivierte `Messagehistory-*.raw`. Diese Dateien zeichnen den vollständigen ON4KST-Sessionverkehr mit RX und TX auf. Loginpasswörter werden redigiert, Chat- und Stationsdaten bleiben jedoch enthalten. Eine solche Datei deshalb nur nach bewusster Prüfung weitergeben. In Datei- und Verzeichnisnamen wird teilweise noch der technische Name `praktiKST` verwendet. Gemeint ist dasselbe Programm. diff --git a/github_docs/de-Konfiguration.md b/github_docs/de-Konfiguration.md index dbaec279..778acddb 100644 --- a/github_docs/de-Konfiguration.md +++ b/github_docs/de-Konfiguration.md @@ -733,12 +733,14 @@ Sind die allgemeine und die QRG-bezogene Antwort gleichzeitig aktiviert, hat die ### Debug- und Contest-History -**Enable debug mode to file** ist standardmäßig aktiviert und wirkt ohne Neustart. Der Schalter steuert zwei getrennte Dateien im Verzeichnis `.praktiKST` des Benutzerprofils: +**Enable debug mode to file** ist standardmäßig aktiviert und wirkt ohne Neustart. Neue Diagnosedateien liegen im Unterverzeichnis `.praktiKST/debug` des Benutzerprofils: - `kst4contest-errors.log` enthält bei aktiviertem Debug-Modus zusätzliche `INFO`- und `FINE`-Diagnosen. Warnungen, Fehler und Exceptions werden unabhängig vom Schalter immer geschrieben. - `Messagehistory.raw` zeichnet den vollständigen ON4KST-Verkehr der aktuellen Programmsitzung auf. Dazu gehören RX und TX, also neben Chat- und Privatnachrichten auch Benutzer-, Status-, Control- und Keepalive-Frames. -Unter Windows liegt die History beispielsweise unter `C:\Users\\.praktiKST\Messagehistory.raw`, unter Linux und macOS unter `~/.praktiKST/Messagehistory.raw`. +Unter Windows liegt die History beispielsweise unter `C:\Users\\.praktiKST\debug\Messagehistory.raw`, unter Linux und macOS unter `~/.praktiKST/debug/Messagehistory.raw`. KST4Contest legt das Verzeichnis bei Bedarf an. Vorhandene Diagnosedateien direkt unter `.praktiKST` werden weder verschoben noch gelöscht. + +Die Datei `kst4contest-errors.log` rotiert bei 20 MiB. Einschließlich der aktiven Datei bleiben höchstens fünf Generationen erhalten. Jede Zeile enthält genau ein Ereignis: @@ -834,6 +836,7 @@ Die Einstellungen befinden sich im Reiter **Station**: | Einstellung | Standardwert | Verwendung | |---|---:|---| | **Enable PSTRotator** | deaktiviert | Startet die UDP-Kommunikation mit PSTRotator | +| **QTF-Filterrichtung als eigene Antennenrichtung übernehmen** | aktiviert, wenn PSTRotator 1 deaktiviert ist | Übernimmt bewusste Richtungseingaben im QTF-Filter als aktuelle eigene Antennenrichtung | | **PSTRotator host** | `127.0.0.1` | Hostname oder IP-Adresse des Rechners, auf dem PSTRotator läuft | | **PSTRotator UDP port** | `12000` | UDP-Port, auf dem PSTRotator die Steuerbefehle empfängt | @@ -860,6 +863,8 @@ Das vollständige UDP-Protokoll ist im [PSTRotatorAz User Manual](https://www.qs ### Übernahme der aktuellen QTF +Beim Programmstart wird die aktuelle eigene QTF mit der im aktiven Root- oder Operatorprofil gespeicherten Vorgabe **QTF** initialisiert. Sie dient nur als Startwert. Änderungen während des Betriebs verändern diese gespeicherte Vorgabe nicht. + KST4Contest fragt PSTRotator alle zwei Sekunden nach der aktuellen Azimutposition und dem Betriebsmodus. Die zurückgemeldete Azimutposition wird als `actualQTF` übernommen. Bei aktivierter PSTRotator-Integration ist das QTF-Feld im Hauptfenster deshalb nicht manuell editierbar. Es zeigt die zuletzt von PSTRotator gemeldete Position. @@ -875,6 +880,10 @@ Diese QTF wird unter anderem verwendet für: Eine empfangene Rotatorposition ist damit nicht nur eine Anzeige. Sie verändert mehrere Funktionen, die auf der aktuellen Antennenrichtung beruhen. +Ohne PSTRotator 1 kann **QTF-Filterrichtung als eigene Antennenrichtung übernehmen** einen direkt in den QTF-Filter eingetragenen Winkel oder eine über dessen Richtungstasten gewählte Richtung in dasselbe `actualQTF` übernehmen. Das funktioniert auch, wenn **Show only QTF** ausgeschaltet ist. **Reset filters**, das Ausschalten des Filters und ein automatisch wiederhergestellter Filterzustand verändern die Antennenrichtung nicht. Die Übernahme erfolgt nur vom Filter zur Antennenrichtung und sendet keinen PSTRotator-Befehl. + +Die Option steht im Abschnitt **Manuelle Antennenrichtung** und ist standardmäßig aktiviert. Das gilt nach dem Update auch für bestehende Root- und Operatorprofile. Wer die Kopplung nicht verwenden möchte, schaltet sie aus; diese Entscheidung wird im jeweiligen Profil gespeichert. Bei aktiviertem PSTRotator 1 ist die Checkbox nicht bedienbar und die Übernahme vorübergehend unwirksam. Die gespeicherte Auswahl bleibt dabei erhalten und gilt wieder, sobald PSTRotator 1 deaktiviert wird. PSTRotator 2 unter SO2R ist von dieser Regel unabhängig. + KST4Contest verwendet derzeit nur den Azimut. Eine Elevationssteuerung oder eine vollständige Azimut-/Elevationsnachführung ist nicht Bestandteil dieser Integration. ### Änderungen übernehmen @@ -986,11 +995,12 @@ Für einen kurzen Wechsel während des Betriebs gibt es **Windows → Use dark m ## Einstellungen speichern -**Save Settings** übernimmt zuerst offene Änderungen und speichert dann die fachlichen Einstellungen und den vollständigen aktuellen Layoutstand. Änderungen an Fenstergrößen und -positionen, relevanten Dividern, verwalteten Tabellenbreiten sowie der Karteneinstellung **Group nearby stations** werden zusätzlich automatisch mit kurzer Verzögerung gespeichert. Ein ausstehender Layoutstand wird beim Programmende noch geschrieben. +**Save Settings** übernimmt zuerst offene Änderungen und speichert dann die fachlichen Einstellungen und den vollständigen aktuellen Layoutstand. Änderungen an Fenstergrößen und -positionen, relevanten Dividern, verwalteten Tabellenbreiten sowie den Karteneinstellungen **Gruppierung** und **Immer im Vordergrund** werden zusätzlich automatisch mit kurzer Verzögerung gespeichert. Ein ausstehender Layoutstand wird beim Programmende noch geschrieben. - Speicherort: unter Linux und macOS `~/.praktiKST/preferences.xml` und unter Windows `%USERPROFILE%\.praktiKST\preferences.xml` (bzw. `C:\Users\\.praktiKST\preferences.xml`) - Der automatische Layout-Writer übernimmt keine noch nicht mit **Save Settings** bestätigten fachlichen Änderungen. - Die Konfigurationsversion 6 ergänzt optionale Spaltenbreiten unter `guiOptions`. Ältere `preferences.xml`-Dateien bleiben lesbar; fehlen Breiten oder sind Einträge ungültig, ermittelt KST4Contest wieder brauchbare Anfangsbreiten. - Die Konfigurationsversion 7 ergänzt `GUIstationMapClusteringEnabled` unter `guiOptions`. Fehlt der Eintrag oder ist sein Wert unbrauchbar, bleibt die räumliche Kartengruppierung aktiviert. -- Ältere Programmversionen ignorieren die zusätzlichen XML-Einträge. Wenn eine ältere Version die Datei vollständig neu speichert, können die Spaltenbreiten und die gespeicherte Auswahl für **Group nearby stations** verloren gehen. +- `GUIstationMapAlwaysOnTop` ist ein optionaler Layoutwert unter `guiOptions`. Fehlt der Eintrag oder ist sein Wert unbrauchbar, verhält sich das Kartenfenster normal und bleibt nicht dauerhaft im Vordergrund. +- Ältere Programmversionen ignorieren die zusätzlichen XML-Einträge. Wenn eine ältere Version die Datei vollständig neu speichert, können die Spaltenbreiten sowie die gespeicherten Einstellungen für **Gruppierung** und **Immer im Vordergrund** verloren gehen. - Bei Problemen: Konfigurationsdatei löschen → KST4Contest erstellt eine neue mit Standardwerten. diff --git a/github_docs/en-Changelog.md b/github_docs/en-Changelog.md index 10c5497f..5f55e6fc 100644 --- a/github_docs/en-Changelog.md +++ b/github_docs/en-Changelog.md @@ -56,6 +56,14 @@ Several operators sharing one computer can now use their own callsigns, locators ### Fixed +- **Reconnect from Settings:** after **Disconnect**, **Connect to ...** remained disabled in the open Settings window. The buttons now follow the Connection Manager state, prevent duplicate attempts and become available again after a completed disconnect or failed attempt. + +- **Reachability controls restored:** the Compose filter bar again contains **Reachability** with **Auto** and the locally enabled bands, plus **Calc selected**. Only an explicit calculation for the selected station may request online elevation data; table rendering, sorting and band selection use existing results only. + +- **More usable on small screens:** the Further Info area is scrollable, empty tables show a clear empty state, filter rows wrap when required and the complete send text is available as a tooltip. Controls and the right-hand window areas are more clearly separated. + +- **Diagnostic files grouped and bounded:** new error logs and ON4KST message-history files are created below `.praktiKST/debug`. Existing files in the former location remain unchanged. The error log rotates at 20 MiB and retains at most five generations including the active file. + - **Consistent font size:** on macOS and Linux JavaFX uses a slightly larger default font than on Windows, which cut off labels in fixed-size controls. KST4Contest now uses the same base font size on all systems; nothing changes on Windows. - **Dark mode after a restart:** the main window always started light, even if dark mode had been active, while the station map stayed dark. All windows now start consistently with the profile's colour scheme. diff --git a/github_docs/en-Configuration.md b/github_docs/en-Configuration.md index 39b056dd..21f1a0bb 100644 --- a/github_docs/en-Configuration.md +++ b/github_docs/en-Configuration.md @@ -733,12 +733,14 @@ When both automatic-reply functions are enabled, the QRG reply takes precedence. ### Debug and contest history -**Enable debug mode to file** is enabled by default and takes effect without restarting the application. The setting controls two separate files in the `.praktiKST` directory of the user profile: +**Enable debug mode to file** is enabled by default and takes effect without restarting the application. New diagnostic files are stored in the `.praktiKST/debug` subdirectory of the user profile: - `kst4contest-errors.log` includes additional `INFO` and `FINE` diagnostics while debug mode is enabled. Warnings, errors and exceptions are written regardless of this setting. - `Messagehistory.raw` records the complete ON4KST traffic of the current program session. It contains RX and TX traffic, including chat, private-message, user, status, control and keepalive frames. -On Windows, the history is stored at `C:\Users\\.praktiKST\Messagehistory.raw`; on Linux and macOS it is stored at `~/.praktiKST/Messagehistory.raw`. +On Windows, the history is stored at `C:\Users\\.praktiKST\debug\Messagehistory.raw`; on Linux and macOS it is stored at `~/.praktiKST/debug/Messagehistory.raw`. KST4Contest creates the directory when needed. Existing diagnostic files directly below `.praktiKST` are neither moved nor deleted. + +The `kst4contest-errors.log` file rotates at 20 MiB. At most five generations are retained, including the active file. Each line contains exactly one event: @@ -836,6 +838,7 @@ The settings are located in the **Station** tab: | Setting | Default | Purpose | |---|---:|---| | **Enable PSTRotator** | disabled | Starts UDP communication with PSTRotator | +| **Use QTF filter direction as antenna heading** | enabled when PSTRotator 1 is disabled | Copies deliberate QTF-filter direction entries to the current local antenna heading | | **PSTRotator host** | `127.0.0.1` | Hostname or IP address of the computer running PSTRotator | | **PSTRotator UDP port** | `12000` | UDP port on which PSTRotator receives control commands | @@ -862,6 +865,8 @@ The complete UDP protocol is documented in the [PSTRotatorAz User Manual](https: ### Updating the local QTF +At program start, the current local QTF is initialised from the **QTF** default stored in the active root or operator profile. This is a start value only. It does not change the stored default during operation. + KST4Contest asks PSTRotator for the current azimuth and operating mode every two seconds. The reported azimuth becomes `actualQTF`. While PSTRotator integration is enabled, the QTF field in the main window is therefore read-only. It displays the most recent position reported by PSTRotator. @@ -877,6 +882,10 @@ This QTF is used by: A received rotator position is therefore more than a displayed value. It changes several functions which depend on the current antenna direction. +Without PSTRotator 1, **Use QTF filter direction as antenna heading** can apply a direction entered in the QTF filter, or selected with one of its compass buttons, to the same `actualQTF`. This also works while **Show only QTF** is switched off. **Reset filters**, switching the filter off and restored filter state do not alter the antenna heading. The transfer is one-way and does not issue a PSTRotator command. + +The option is grouped under **Manual antenna heading** and is enabled by default, including for existing root and operator profiles after updating. Clear it to store a profile-specific opt-out. While PSTRotator 1 is enabled, the checkbox is unavailable and the transfer is temporarily inactive. Its stored state is retained, so disabling PSTRotator 1 restores the previous choice. PSTRotator 2 under SO2R is independent of this rule. + The current integration uses azimuth only. Elevation control and complete azimuth/elevation tracking are outside its present scope. ### Applying changed settings @@ -1043,11 +1052,12 @@ For a quick change during operation, use **Windows → Use dark mode design** an ## Saving Settings -**Save Settings** applies pending changes first, then stores functional settings and the complete current layout. Changes to window sizes and positions, relevant dividers, managed table-column widths and the **Group nearby stations** map setting are also saved automatically after a short delay. Any pending layout update is written when the programme exits. +**Save Settings** applies pending changes first, then stores functional settings and the complete current layout. Changes to window sizes and positions, relevant dividers, managed table-column widths and the **Grouping** and **Always on top** map settings are also saved automatically after a short delay. Any pending layout update is written when the programme exits. - Storage location: `~/.praktiKST/preferences.xml` on Linux and macOS and `%USERPROFILE%\.praktiKST\preferences.xml` (or `C:\Users\\.praktiKST\preferences.xml`) on Windows - The automatic layout writer does not copy functional changes which have not yet been confirmed with **Save Settings**. - Configuration version 6 adds optional column-width entries below `guiOptions`. Older `preferences.xml` files remain readable. Missing or invalid widths simply cause KST4Contest to calculate useful initial widths again. - Configuration version 7 adds `GUIstationMapClusteringEnabled` below `guiOptions`. If the entry is missing or unusable, spatial map clustering remains enabled. -- Older programme versions ignore the additional XML entries. If an older version rewrites the complete file, column widths and the stored **Group nearby stations** choice may be lost. +- `GUIstationMapAlwaysOnTop` is an optional layout value below `guiOptions`. If the entry is missing or unusable, the map window uses normal window behaviour and does not remain permanently in front. +- Older programme versions ignore the additional XML entries. If an older version rewrites the complete file, column widths and the stored **Grouping** and **Always on top** choices may be lost. - If you encounter problems: delete the configuration file → KST4Contest will create a new one with default values. diff --git a/github_docs/en-Home.md b/github_docs/en-Home.md index 48a0aeff..d0b38513 100644 --- a/github_docs/en-Home.md +++ b/github_docs/en-Home.md @@ -104,12 +104,12 @@ The error log is stored at: | Operating system | Path | |---|---| -| Linux / macOS | `~/.praktiKST/kst4contest-errors.log` | -| Windows | `C:\Users\\.praktiKST\kst4contest-errors.log` | +| Linux / macOS | `~/.praktiKST/debug/kst4contest-errors.log` | +| Windows | `C:\Users\\.praktiKST\debug\kst4contest-errors.log` | Please inspect the file briefly before uploading it. An error log mainly contains technical information, but depending on the problem it may also include local file paths or other contextual data. -When **Settings → Messagehandling → Enable debug mode to file** is enabled, the same directory also contains `Messagehistory.raw` and possibly archived `Messagehistory-*.raw` files. These files record the complete ON4KST session traffic in both directions. Login passwords are redacted, but chat and station data remains present. Share such a file only after reviewing it deliberately. +When **Settings → Messagehandling → Enable debug mode to file** is enabled, the `debug` directory also contains `Messagehistory.raw` and possibly archived `Messagehistory-*.raw` files. These files record the complete ON4KST session traffic in both directions. Login passwords are redacted, but chat and station data remains present. Share such a file only after reviewing it deliberately. Some file and directory names still use the technical name `praktiKST`. They refer to the same program. diff --git a/github_docs/en-User-Interface.md b/github_docs/en-User-Interface.md index 0acb6d7c..bab2e902 100644 --- a/github_docs/en-User-Interface.md +++ b/github_docs/en-User-Interface.md @@ -103,7 +103,11 @@ Each status cell has a tooltip containing the legend and the state derived for t ![Band-specific Worked status and worked grid squares](worked_band_status.png) -**Sorting**: Click column headers. QRB sorting is numerical (corrected in v1.22). +On its first build, the user list is sorted alphabetically by complete callsign, ignoring letter case. New stations are inserted at the corresponding position. A sort subsequently selected through a column header is retained across new stations, data updates and filter changes. QRB is sorted numerically. + +Station selection is shared by the user list, PM, Public and QSO-of-the-other tables, priority display, AP timeline and map. A selection made in another view scrolls the user list to the chat member's current position and highlights the row. If a filter hides that station, it is temporarily added to the user list; the stored filters and exactly filtered map view remain unchanged. Callsign and chat category jointly identify the station. + +A callsign shown in bold marks a station which actually joined the chat during the current session. The emphasis remains visible for ten minutes. The initial user list and a complete resynchronisation after a reconnect do not create these markers. The Day design uses a contrasting dark text colour, and the Evening design uses an appropriately light colour. A callsign displayed in green and bold indicates a directional opportunity derived from a directed message. The marker applies to the sender of that message and remains visible for no more than five minutes. Derivation and limitations: [Directional Opportunities from Directed Messages](en-Features#directional-opportunities-from-directed-messages). @@ -180,8 +184,12 @@ This avoids having to move the divider merely to read an individual long message The filter bar is located above the chat-member table. Filters can be combined; a station remains visible only if it satisfies every active condition. +Subtly bordered surfaces group **QRB**, **QTF** including direction buttons, **Hide**, **Show** and **Reachability** by function. **Find** and **Reset filters** remain separate controls. The grouping changes presentation only; filter state, reset behaviour and map coupling remain shared. + ![Wrapped filter bar in a narrow chat-member view](filter_bar_wrapped.png) +> **Note:** This screenshot still shows the previous filter-bar layout. It will be replaced after the new compact, wrapping layout has completed its visual check. + ### Station Filters | Control | Effect | @@ -198,6 +206,8 @@ The filter bar is located above the chat-member table. Filters can be combined; | **Tropo >=0dB** | Shows stations with a calculated non-negative SSB margin | | **AS next 5m** | Shows stations with a current AirScout window or one expected within the next five minutes | +By default, a valid direct entry in the QTF group or a compass-button click also updates the current local antenna heading. This explicit transfer works whether **Show only QTF** is on or off. Resetting or merely disabling the filter does not change the antenna heading. The profile-specific option **Use QTF filter direction as antenna heading** under **Station → Manual antenna heading** can disable this behaviour. PSTRotator 1 takes temporary priority without changing that saved choice. + For **New bands**, KST4Contest evaluates current QRGs, band information in the name field and active callsign variants together. Manual NOT-QRV marks take precedence. The **Tropo >=0dB** filter removes only stations for which a completed calculation returned a negative margin. Stations with pending or failed calculations remain visible. Otherwise, a missing API result would incorrectly be treated as proof that the path is unsuitable. @@ -254,21 +264,31 @@ In plain terms: the filters determine the table contents, but no longer enforce The lower-right panel combines the messages associated with the selected station. This includes public messages, private messages to the local station and, where visible in the chat, private messages addressed to other stations. -The selected filter controls which of these messages are displayed. Under **Settings → GUI**, the default filter can be set to: +The selected filter is a separate dimension which controls the message type shown for the selected station. Under **Settings → GUI**, its initial value can be set to: -- all messages, -- private messages to the local station, -- private messages to other stations, or -- public messages. +- **pm to me**: private messages between the selected and local station, plus public messages from the selected station whose structured leading address names the local login callsign, +- **pm to other**: private messages sent by the selected station to other participants, +- **Public msgs**: public messages sent by the selected station to `ALL`, +- **nothing**: all messages involving the selected station in its chat category, without an additional message-type or receiver restriction. + +A local callsign mentioned only somewhere in ordinary message text does not make a public line a **pm to me** message. A public line qualifies only when the ON4KST message begins with its explicit parenthesised address. This setting changes the Further Info display only. Messages are neither discarded nor removed from the other message tables, and the filter can be changed at any time for the currently selected station. -The lower part of the panel contains per-band **Not QRV** marks for the selected station. Individual controls are shown for the bands enabled in the local station settings. **tag not qrv all** sets or removes the mark for every supported band, including bands which are not currently visible. +A horizontal divider inside Further Info divides the available height between the message table and station actions. Moving it immediately makes the table taller or shorter. NOT-QRV and Sked use their own vertical scroll region in the lower area, while **Look up and show** remains pinned below it. The divider position is stored with the active root or operator profile's layout and is clamped to a usable value in a smaller window. Empty tables retain their column structure and show **No entries**. + +The station header combines the callsign, chat category, QTF, QRB, last activity and detected bands in a compact flow. It does not repeat the locator; the locator remains available in the other station views. In a narrow panel, the metadata wraps onto additional lines without clipping. **Show messages** sits directly in front of the four message filters and flows with them. + +The **NOT QRV** and **Sked alert** areas sit next to each other while the actual right-panel width is sufficient. Moving the splitter further to the right stacks the two bordered groups. Controls can also wrap inside their group, without introducing a horizontal scroll area. + +The lower part of the panel contains per-band **Not QRV** marks for the selected station. Compact toggle buttons are shown for the bands enabled in the local station settings. **all** sets or removes the mark for every supported band, including bands which are not currently visible. The change immediately affects the **NOT QRV @** column, band opportunities and the corresponding filters. It is stored in the internal database and restored after a restart. ![Per-band NOT-QRV marks in the Further Info panel](not_qrv_controls.png) +> **Screenshot note:** This image still shows the previous checkbox controls. It will be replaced after manual acceptance of the compact toggle buttons. + The current **Priority score** of the selected station is displayed in the same section. **Sked fail** marks an unsuccessful attempt and strongly reduces the score of the normalised base callsign. **Reset fail** removes the mark. It applies to all active suffix and category variants of the station and remains active for the current program session. @@ -286,6 +306,8 @@ The controls underneath are used to create a sked: ![Sked controls in the Further Info section](sked_controls.png) +> **Screenshot note:** This image still shows the previous separate layout. It will be replaced after manual acceptance of the responsive **NOT QRV** and **Sked alert** groups. + The proposed band is derived from recent QRG and name information for the station. It can be changed explicitly before creating the sked. The mode selection only affects the Win-Test handover; the internal sked and reminder PMs work independently. **Create sked** always creates the appointment inside KST4Contest first. If the Win-Test network listener is active, KST4Contest then attempts an additional handover to Win-Test. If no QRG matching the selected band can be found or Win-Test cannot be reached, the internal sked, its priority contribution and any scheduled reminders remain intact. @@ -296,7 +318,13 @@ The complete derivation and limitations are described under [Skeds and Sked Remi ## Priority List -The compact priority bar is located on the right-hand side between the user list and the Further Info section. It displays the two currently highest-ranked candidates directly in the main window: +The compact bar is located on the right-hand side between the user list and the Further Info section. The **New joined** group appears on the left when needed, while the independent **Priority** group remains on the right. + +**New joined** shows actual chat joins for ten minutes, newest first. Up to five stations are displayed directly when space permits; additional entries, or entries moved out at a narrow width, remain available in the compact **more** dropdown. The initial user list, a complete resynchronisation, profile-only updates and the local callsign do not create entries. A station is removed immediately when it leaves. A later real rejoin starts a new ten-minute period. Complete callsign and chat category remain separate identities. + +Clicking an entry uses the shared station selection, updates Further Info and scrolls the user list to the station when necessary. + +The **Priority** group displays the two currently highest-ranked candidates directly in the main window: ```text Priority: 1 CALLSIGN SCORE 2 CALLSIGN SCORE more @@ -308,6 +336,8 @@ The **more** button opens a separate window containing up to 15 candidates. The ![Priority Score, compact candidate list and Further Info controls](priority_score_overview.png) +> **Note:** This screenshot still shows the previous Further Info geometry. It will be replaced after the new scrollable, height-constrained station panel has completed its visual check. + Stations with a score of `0` are not included in the priority list. They remain visible in the user list so that the reason for their exclusion can be examined and, for example, an incorrect NOT-QRV mark can be changed. The score is calculated for the normalised base callsign. Several active variants such as `9A0BB-2` and `9A0BB-70` may therefore display the same value in the user list. They nevertheless remain separate message targets. @@ -318,6 +348,14 @@ Calculation and limitations: [Priority Score and Priority List](en-Features#prio --- +## AP Timeline + +The AP timeline shows opportunities in the upcoming window relative to now. Its subtle minute axis runs from `0 min` on the left to `+30 min` on the right. Ticks normally use five-minute intervals; a narrow view increases the interval so the labels remain readable. + +The four candidate lanes directly reflect the existing priority selection: **In beam 1**, **In beam 2**, **Priority 1** and **Priority 2**. **Skeds** have a separate lane. The existing colour coding, antenna emphasis and click-to-select behaviour remain in effect. + +--- + ## Station Map The station map can be opened in two ways: @@ -329,8 +367,12 @@ The map uses the stations which remain visible after applying the current user-l ![Station map with a selected station and visible path analysis](station_map_path_analysis.png) +> **Screenshot note:** This image still shows the previous marker label and neither the new context menu nor the Worked-square overlay. It will be replaced after manual map acceptance. + ### Selecting a Station +A marker displays only its callsign as visible text. Locator, detected bands, `B+` state and the most recently known QRGs remain available in its tooltip. + A single station marker can be selected directly. KST4Contest then: 1. selects the corresponding chat member, @@ -340,12 +382,18 @@ A single station marker can be selected directly. KST4Contest then: Chat logins with the same normalised base callsign and position may share one marker. They nevertheless remain separate message targets inside KST4Contest. -When **Group nearby stations** is selected, markers which are too close together at lower zoom levels are displayed as a cluster containing the number of stations. Clicking the cluster zooms into that area. A concrete station is selected only after an individual marker becomes visible and is clicked. Clearing the checkbox displays every positionable station as an individual marker at every zoom level. +When **Grouping** is selected, markers which are too close together at lower zoom levels are displayed as a cluster containing the number of stations. Clicking the cluster zooms into that area. A concrete station is selected only after an individual marker becomes visible and is clicked. Clearing the checkbox displays every positionable station as an individual marker at every zoom level. Its tooltip explains the compact switch's full purpose. -The change takes effect immediately without changing the current zoom, viewport or station selection. It is saved automatically and restored at the next programme start. If no value has been stored yet, **Group nearby stations** remains selected so existing installations retain the previous behaviour. +The change takes effect immediately without changing the current zoom, viewport or station selection. It is saved automatically and restored at the next programme start. If no value has been stored yet, **Grouping** remains selected so existing installations retain the previous behaviour. This switch controls only the spatial clusters on screen. Active chat variants of the same normalised base callsign may still share one geographical marker and remain separate message targets regardless of this setting. +Right-clicking a marker or a row in the user list opens the same station context menu. It contains **Message snippets**, **Create sked…**, band-specific **Tag NOT QRV** and **Mark as worked** actions, and **Calculate reachability**. When a marker represents several complete callsigns or chat categories, the concrete target must be chosen first. No action is applied arbitrarily to the first entry. + +The snippet action only extends the send field and does not send by itself. The sked editor keeps the target captured when it opened; a later selection change does not alter that draft. **Mark as worked** uses the same additive Worked and database path as a received logger `contactinfo` and never removes an existing mark. + +**Calculate reachability** explicitly starts the existing path calculation for the chosen band. When invoked from the user list, KST4Contest opens the map and displays the loading state, result or error for that exact target. + For a selected station, the header additionally shows: - the complete callsign, @@ -357,6 +405,10 @@ For a selected station, the header additionally shows: Long header content is shortened. The complete text remains available in its tooltip. +**Reset filters** invokes the same action as the button in the user list. It clears all enabled station filters, callsign search and direction filters. The display-only **Grid color** option remains unchanged, as do sorting, station selection, zoom and viewport. **Always on top** affects only the map window, is applied immediately and is saved automatically for the operator profile. It is disabled when no value has been stored. + +While **Grid color** is enabled, the map marks persistently known, already worked four-character Maidenhead squares with a black outline and a 30 percent black fill overlay. The overlay uses the same persistent Worked-grid state as **Only new grids**, so a square remains visible even when it currently contains no chat station. Neither outline nor fill overlay is drawn while **Grid color** is disabled. + ### Clearing the Selection with Reset View **Reset view** clears the selected station without changing the map position or zoom level. @@ -433,6 +485,8 @@ The lower part of the main window contains three global message tabs. Their cont | **DXCluster messages** | DX cluster messages received through ON4KST | | **QSO of the other** | Directed messages between two other stations | +Clicking a row under **Public messages** or **QSO of the other** selects its sender as the current station and scrolls the user list to the matching callsign and chat category. The global tab contents remain unchanged. + ![Global message tabs in the main window](global_message_tabs.png) In **QSO of the other**, sender and receiver are displayed separately. **Last QRG TX** and **Last QRG RX** contain the frequencies most recently known for the two stations. They do not necessarily represent the frequency discussed in the displayed conversation. @@ -498,6 +552,8 @@ Functions which communicate with the server are available only after the ON4KST KST4Contest automatically stores programme-window sizes and positions, relevant dividers and manually changed table-column widths in the configuration file after a short delay. Any pending layout update is written when the programme exits. **Save Settings** is not required for these changes, but still stores the complete current state, including the layout. +This includes the horizontal divider between the message table and the station actions in Further Info. Its value belongs to the active root or operator profile; when restoring it, KST4Contest clamps the value to the height available in the current window. + The main window is additionally checked against the visible area of the primary screen during startup. If the stored size is too large, KST4Contest reduces and moves the window so that it remains accessible. The complete process is described under [Screen-Aware Main Window Sizing](en-Features#screen-aware-main-window-sizing-from-v141). The other programme windows do not currently use this additional size restriction. If, for example, the separate monitor window appears too large after moving to a smaller screen, correcting it manually is sufficient; the new size is saved automatically.