diff --git a/app-desktop/src/main/i18n/strings_de.properties b/app-desktop/src/main/i18n/strings_de.properties index ea8c48c4..6a5fd14e 100644 --- a/app-desktop/src/main/i18n/strings_de.properties +++ b/app-desktop/src/main/i18n/strings_de.properties @@ -235,6 +235,7 @@ column.lastQrgTx=Letzte QRG TX filter.find=Suchen... filter.reset=Filter zurücksetzen filter.showOnlyQrb=Nur QRB [km] <= +filter.showOnlyQrbAbove=Nur QRB [km] > filter.showOnlyQtf=Nur QTF: filter.hideWorked=Gearbeitete ausblenden: filter.inactiveStations=Inaktive Stationen diff --git a/app-desktop/src/main/i18n/strings_en.properties b/app-desktop/src/main/i18n/strings_en.properties index 446275b5..c693efb6 100644 --- a/app-desktop/src/main/i18n/strings_en.properties +++ b/app-desktop/src/main/i18n/strings_en.properties @@ -244,6 +244,7 @@ column.lastQrgTx=Last QRG TX filter.find=Find... filter.reset=Reset filters filter.showOnlyQrb=Show only QRB [km] <= +filter.showOnlyQrbAbove=Show only QRB [km] > filter.showOnlyQtf=Show only QTF: filter.hideWorked=Hide worked: filter.inactiveStations=Inactive stations diff --git a/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java b/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java index 258b43bd..e74e59d9 100644 --- a/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java +++ b/app-desktop/src/main/java/kst4contest/view/Kst4ContestApplication.java @@ -2628,10 +2628,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos if (state.isOn(kst4contest.view.compose.StationFilter.NEW_BANDS) && !isNewBandOpportunity(member)) return false; if (state.isOn(kst4contest.view.compose.StationFilter.AIRSCOUT_NEXT_5_MIN) && !hasAsWindowInNextMinutes(member, 5)) return false; - if (state.isOn(kst4contest.view.compose.StationFilter.MAX_QRB)) { - Double qrb = member.getQrb(); - if (qrb == null || qrb > state.getMaxQrbKm()) return false; - } + if (!state.acceptsQrb(member.getQrb())) return false; if (state.isOn(kst4contest.view.compose.StationFilter.QTF)) { Double qtf = member.getQTFdirection(); 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 057b60ca..62b923d0 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableState.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/DataTableState.kt @@ -275,7 +275,14 @@ class DataTableState( var contentRevision by mutableStateOf(0) private set + /** + * Redraws the rows and filters them again. The filter reads mutable row fields (a + * station's QRB, worked and QRV flags, its last activity), so a cached [visibleRows] + * from before the change could keep a row the filter now excludes, or hide one it + * now lets through -- in the list and in the station map, which draws the same rows. + */ fun forceRedraw() { + invalidateOrder() contentRevision++ } @@ -299,6 +306,7 @@ class DataTableState( */ val currentRows = rows val currentFilter = rowFilter + contentRevision // read for the subscription: forceRedraw drops the cache val currentColumn = sortedColumnId val ascending = sortAscending 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 6d11792c..b0644d62 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterBar.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterBar.kt @@ -48,11 +48,19 @@ fun StationFilterBar( modifier = modifier.fillMaxWidth().padding(horizontal = 2.dp, vertical = 1.dp), verticalArrangement = Arrangement.spacedBy(1.dp), ) { - // ROW 1: Reset | QRB | QTF + bearings - Row( - modifier = Modifier.fillMaxWidth().height(IntrinsicSize.Min), + /* + * ROW 1: Reset | QRB max + min | QTF | bearings + * + * It wraps (#21): with both QRB filters the line no longer fits a narrow station + * pane, and a plain row cut the bearing buttons off at the right edge. Each group + * stays together on its line; only the QRB pair and the bearings may break up + * inside their group when even that group is wider than the pane. + */ + FlowRow( + modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, + verticalArrangement = Arrangement.spacedBy(1.dp), + itemVerticalAlignment = Alignment.CenterVertically, ) { // Reset filters FilterButton(strings.filterReset, on = false, isAccent = true) { @@ -60,65 +68,105 @@ fun StationFilterBar( onChanged() } - VerticalSeparator() - - // QRB checkbox + field - CompactCheckbox( - checked = filters.maxQrbEnabled, - onCheckedChange = { - filters.maxQrbEnabled = it - onChanged() - }, - ) - Text(strings.filterShowOnlyQrb, style = MaterialTheme.typography.bodySmall) - CompactTextField( - value = if (filters.maxQrbKm.rem(1) == 0.0) filters.maxQrbKm.toInt().toString() else filters.maxQrbKm.toString(), - onValueChange = { - val parsed = it.toDoubleOrNull() - if (parsed != null) { - filters.maxQrbKm = parsed - if (filters.maxQrbEnabled) onChanged() - } else if (it.isEmpty()) { - filters.maxQrbKm = 0.0 - if (filters.maxQrbEnabled) onChanged() + // 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) + ) } - }, - modifier = Modifier.width(50.dp) - ) - - VerticalSeparator() - - // QTF checkbox + field + bearings - CompactCheckbox( - checked = filters.qtfEnabled, - onCheckedChange = { - filters.qtfEnabled = it - onChanged() - }, - ) - Text(strings.filterShowOnlyQtf, style = MaterialTheme.typography.bodySmall) - CompactTextField( - value = if (filters.qtfDegrees.rem(1) == 0.0) filters.qtfDegrees.toInt().toString() else filters.qtfDegrees.toString(), - onValueChange = { - val parsed = it.toDoubleOrNull() - if (parsed != null) { - filters.qtfDegrees = parsed - filters.bearing = null - if (filters.qtfEnabled) onChanged() - } else if (it.isEmpty()) { - filters.qtfDegrees = 0.0 - filters.bearing = null - if (filters.qtfEnabled) onChanged() + 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) + ) } - }, - modifier = Modifier.width(50.dp) - ) - Text("± BW/2", style = MaterialTheme.typography.bodySmall) + } + } - Bearing.entries.forEach { bearing -> - FilterButton(bearing.name, on = filters.bearing == bearing) { - filters.toggleBearing(bearing) - onChanged() + // 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() + } } } } @@ -235,6 +283,31 @@ private fun FilterButton(label: String, on: Boolean, isAccent: Boolean = false, } } +/** Controls that belong together and move to the next line of a wrapping row as one. */ +@Composable +private fun FilterGroup(content: @Composable () -> Unit) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + content() + } +} + +/** + * 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. + */ +@Composable +private fun GroupSeparator() { + Box( + modifier = Modifier + .width(Density.HAIRLINE) + .height(18.dp) + .background(MaterialTheme.colorScheme.outline), + ) +} + /** Thin vertical separator matching JavaFX's group dividers. */ @Composable private fun VerticalSeparator() { 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 b2f75d08..b1f767cf 100644 --- a/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterState.kt +++ b/app-desktop/src/main/kotlin/kst4contest/view/compose/StationFilterState.kt @@ -18,6 +18,7 @@ enum class StationFilter { NEW_BANDS, AIRSCOUT_NEXT_5_MIN, MAX_QRB, + MIN_QRB, QTF, SEARCH_TEXT, HIDE_INACTIVE, @@ -33,6 +34,17 @@ enum class StationFilter { HIDE_WORKED_3, } +/** + * What a QRB field of the filter bar makes of the text typed into it: a distance in km, or + * `null` when the text is none. + * + * `toDouble` alone also reads "NaN" and "Infinity", and a pasted "Infinity" as the minimum + * hid every station while "NaN" let every one through. A negative distance means nothing + * either. All of these are refused, so the field keeps its last valid value (#21). + */ +internal fun parseQrbKm(text: String): Double? = + text.toDoubleOrNull()?.takeIf { it.isFinite() && it >= 0.0 } + /** 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), @@ -99,6 +111,16 @@ class StationFilterState { /** Only meaningful while [maxQrbEnabled]; a distance alone filters nothing. */ var maxQrbKm: Double by mutableStateOf(0.0) + var minQrbEnabled: Boolean + get() = isOn(StationFilter.MIN_QRB) + set(value) = switch(StationFilter.MIN_QRB, value) + + /** + * Only meaningful while [minQrbEnabled]. Exclusive: a station exactly this far away is + * hidden, so that "QRB > 300" and "QRB <= 300" split the list without overlap (#21). + */ + var minQrbKm: Double by mutableStateOf(0.0) + var qtfEnabled: Boolean get() = isOn(StationFilter.QTF) set(value) = switch(StationFilter.QTF, value) @@ -130,6 +152,23 @@ class StationFilterState { fun isOn(filter: StationFilter): Boolean = switched[filter] == true + /** + * Whether a station [qrb] km away passes the distance filters that are switched on. + * + * Both on make a range: `min < qrb <= max`. A station without a known distance is + * hidden as soon as either is on — `null` is unknown, not zero, and an unknown distance + * can be in neither range. + */ + fun acceptsQrb(qrb: Double?): Boolean { + val max = isOn(StationFilter.MAX_QRB) + val min = isOn(StationFilter.MIN_QRB) + if (!max && !min) return true + if (qrb == null) return false + if (max && qrb > maxQrbKm) return false + if (min && qrb <= minQrbKm) return false + return true + } + fun setHideWorkedOn(band: WorkedBandFilter, hide: Boolean) = switch(band.filter, hide) fun isHideWorkedOn(band: WorkedBandFilter): Boolean = isOn(band.filter) 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 bbbf704f..c413335f 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 @@ -129,7 +129,19 @@ object StationMapWindow { val antennaBeamWidthDeg = mainWindowState.prefs.stn_antennaBeamWidthDeg val maxQrbKm = mainWindowState.prefs.stn_maxQRBDefault - val markers by remember(mainWindowState.stations.rows, mainWindowState.stations.contentRevision, selectedMember, mainWindowState.activeBands) { + /* + * The markers are the stations the list shows, not all of them (#21): the + * JavaFX map took the sorted, filtered list (StationMapBridge.refreshNow), and + * the status line below already says "filtered view active". A selected + * station that a filter hides gets no marker either, as it did there. + */ + val markers by remember( + mainWindowState.stations.rows, + mainWindowState.stations.rowFilter, + mainWindowState.stations.contentRevision, + selectedMember, + mainWindowState.activeBands, + ) { derivedStateOf { val builder = MapCallsignRawSnapshotBuilder() val activeBands = mainWindowState.activeBands @@ -140,7 +152,7 @@ object StationMapWindow { } builder.buildSnapshots( - mainWindowState.stations.rows, + mainWindowState.stations.visibleRows, selectedMember, bandSet ) @@ -152,17 +164,29 @@ object StationMapWindow { // Read through updated state so a caller passing a new lambda is never served a stale one. val currentPathAnalysis by rememberUpdatedState(pathAnalysis) - LaunchedEffect(selectedMember) { + /* + * Whether the selected station has a marker. A filter can hide it (#21), and a + * station without a locator never has one; then there is nothing to analyse. + */ + val selectedHasMarker = selectedMember != null && + markers.any { it.callSignRaw() == selectedMember.callSignRaw } + + LaunchedEffect(selectedMember, selectedHasMarker) { if (selectedMember == null) { pathAnalysisResult = null return@LaunchedEffect } - + + val selectedSnapshot = markers.find { it.callSignRaw() == selectedMember.callSignRaw } + if (selectedSnapshot == null) { + // No marker: no request, and no "Loading" for a station the map does not show. + pathAnalysisResult = null + return@LaunchedEffect + } + val analysis = currentPathAnalysis() if (analysis != null) { - val selectedSnapshot = markers.find { it.callSignRaw() == selectedMember.callSignRaw } - - if (selectedSnapshot != null && selectedSnapshot.hasUsablePosition()) { + if (selectedSnapshot.hasUsablePosition()) { /* * Pan to the station and leave the zoom alone. The Leaflet map * did exactly this (focusCallsignRaw) and touched the zoom only @@ -183,8 +207,8 @@ object StationMapWindow { pathAnalysisResult = PathAnalysisResult.loading( prefs.stn_loginLocatorMainCat, - selectedSnapshot?.locator6() ?: "", - selectedSnapshot?.callSignRaw() ?: "" + selectedSnapshot.locator6(), + selectedSnapshot.callSignRaw() ) analysis.request(selectedMember, selectedSnapshot) { result -> 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 3d248afd..885ae960 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/DataTableStateTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/DataTableStateTest.kt @@ -322,6 +322,26 @@ class DataTableStateTest { assertSame(kept, table.selectedRow) } + + /** + * The filter reads fields of the row objects, which change in place: a station's QRB, + * its worked flags, its last activity. forceRedraw is how the application says so, and + * the cached visible rows must be filtered again after it (#21). + */ + @Test + fun `a redraw filters rows again whose fields changed in place`() { + val table = stationTable() + val moving = Station("DN9APW", 100) + table.replaceRows(listOf(moving, Station("DO5AMF", 400))) + table.rowFilter = { it.qrb > 300 } + assertEquals(listOf("DO5AMF"), table.visibleRows.map { it.callSign }) + + moving.qrb = 500 + table.forceRedraw() + + assertEquals(listOf("DN9APW", "DO5AMF"), table.visibleRows.map { it.callSign }.sorted(), + "the cached rows from before the change were handed back") + } } /** diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/StationFilterBarScreenTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationFilterBarScreenTest.kt new file mode 100644 index 00000000..74b1e1a1 --- /dev/null +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationFilterBarScreenTest.kt @@ -0,0 +1,157 @@ +package kst4contest.view.compose + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.ComposeUiTest +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.runComposeUiTest +import androidx.compose.ui.unit.dp +import kst4contest.model.Band +import kst4contest.view.compose.tabs.checkBeside +import kst4contest.view.compose.tabs.fieldBeside +import kst4contest.view.compose.tabs.showTab +import kst4contest.view.compose.tabs.toggle +import kst4contest.view.compose.tabs.type +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.util.EnumSet + +/** + * The QRB part of the station filter bar, drawn (#21). + * + * [StationFilterStateTest] pins which distances pass; this pins that the two QRB controls + * on screen write into their own filter and not into the other one. The two labels differ + * only in `<=` and `>`, so a swapped binding would look right and filter the wrong side. + * + * Headless: only a composition, no window. + */ +@OptIn(ExperimentalTestApi::class) +class StationFilterBarScreenTest { + + private val maxLabel = "Show only QRB [km] <=" + private val minLabel = "Show only QRB [km] >" + + private class Bar { + val filters = StationFilterState() + var changes = 0 + } + + private fun ComposeUiTest.showBar(): Bar { + val bar = Bar() + showTab { + StationFilterBar( + filters = bar.filters, + activeBands = EnumSet.of(Band.B_144), + onChanged = { bar.changes++ }, + ) + } + return bar + } + + @Test + fun theMinimumQrbControlsWriteTheMinimumFilterOnly() = runComposeUiTest { + val bar = showBar() + + type(minLabel, "300") + toggle(minLabel) + + assertTrue(bar.filters.minQrbEnabled) + assertEquals(300.0, bar.filters.minQrbKm) + assertEquals(setOf(StationFilter.MIN_QRB), bar.filters.activeFilters) + assertEquals(0.0, bar.filters.maxQrbKm, "the minimum field wrote into the maximum") + assertTrue(bar.changes > 0, "switching the filter on did not reapply the filters") + checkBeside(minLabel).assertIsOn() + checkBeside(maxLabel).assertIsOff() + } + + @Test + fun bothQrbControlsTogetherMakeARange() = runComposeUiTest { + val bar = showBar() + + type(maxLabel, "500") + toggle(maxLabel) + type(minLabel, "200") + toggle(minLabel) + + assertEquals(setOf(StationFilter.MAX_QRB, StationFilter.MIN_QRB), bar.filters.activeFilters) + fieldBeside(maxLabel).assertTextEquals("500") + fieldBeside(minLabel).assertTextEquals("200") + assertFalse(bar.filters.acceptsQrb(150.0)) + assertTrue(bar.filters.acceptsQrb(350.0)) + assertFalse(bar.filters.acceptsQrb(650.0)) + } + + /** A pasted "Infinity" as the minimum hid every station, a "NaN" let every one through. */ + @Test + fun theQrbFieldsKeepTheirValueWhenTheTextIsNoDistance() = runComposeUiTest { + val bar = showBar() + type(maxLabel, "500") + type(minLabel, "200") + + listOf("Infinity", "NaN", "-5").forEach { text -> + type(maxLabel, text) + type(minLabel, text) + } + + assertEquals(500.0, bar.filters.maxQrbKm) + assertEquals(200.0, bar.filters.minQrbKm) + fieldBeside(maxLabel).assertTextEquals("500") + fieldBeside(minLabel).assertTextEquals("200") + } + + @Test + fun resettingTheFiltersUnticksTheMinimumQrb() = runComposeUiTest { + val bar = showBar() + toggle(minLabel) + checkBeside(minLabel).assertIsOn() + + onNodeWithText("Reset filters").performClick() + waitForIdle() + + checkBeside(minLabel).assertIsOff() + assertFalse(bar.filters.minQrbEnabled) + } + + /** + * The first row used to be a plain row: with both QRB filters in it, a narrow station + * pane cut the bearing buttons off at the right edge. It wraps now, and every control + * has to stay on screen. + */ + @Test + fun aNarrowBarWrapsInsteadOfCuttingControlsOff() = runComposeUiTest { + val filters = StationFilterState() + val width = 300.dp + setContent { + Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { + Box(Modifier.width(width)) { + StationFilterBar(filters = filters, activeBands = EnumSet.of(Band.B_144), onChanged = {}) + } + } + } + waitForIdle() + + val limit = with(density) { width.toPx() } + val controls = Bearing.entries.map { onNodeWithText(it.name) } + + listOf( + onNodeWithText(maxLabel), + onNodeWithText(minLabel), + onNodeWithText("Show only QTF:"), + fieldBeside(maxLabel), + fieldBeside(minLabel), + ) + controls.forEach { node -> + node.assertIsDisplayed() + val right = node.fetchSemanticsNode().boundsInRoot.right + assertTrue(right <= limit + 0.5f, "a control ends at $right px, beyond the $limit px wide bar") + } + } +} diff --git a/app-desktop/src/test/kotlin/kst4contest/view/compose/StationFilterStateTest.kt b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationFilterStateTest.kt index 08e9b506..6466920f 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/StationFilterStateTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationFilterStateTest.kt @@ -131,6 +131,108 @@ class StationFilterStateTest { assertEquals(setOf(StationFilter.MAX_QRB), filters.activeFilters) } + @Test + fun `the minimum QRB filter needs its own switch`() { + val filters = StationFilterState() + + filters.minQrbKm = 300.0 + assertTrue(filters.activeFilters.isEmpty(), "a distance alone filters nothing") + + filters.minQrbEnabled = true + assertEquals(setOf(StationFilter.MIN_QRB), filters.activeFilters) + } + + @Test + fun `resetting switches both QRB filters off`() { + val filters = StationFilterState() + filters.maxQrbEnabled = true + filters.minQrbEnabled = true + + filters.reset() + + assertTrue(filters.activeFilters.isEmpty()) + assertFalse(filters.minQrbEnabled) + assertFalse(filters.maxQrbEnabled) + } + + @Test + fun `without a QRB filter every distance passes, an unknown one too`() { + val filters = StationFilterState() + filters.maxQrbKm = 100.0 + filters.minQrbKm = 500.0 + + assertTrue(filters.acceptsQrb(50.0)) + assertTrue(filters.acceptsQrb(1000.0)) + assertTrue(filters.acceptsQrb(null)) + } + + /** The behaviour the controller predicate had before #21 moved it here. */ + @Test + fun `the maximum QRB keeps stations up to and including the distance`() { + val filters = StationFilterState() + filters.maxQrbEnabled = true + filters.maxQrbKm = 300.0 + + assertTrue(filters.acceptsQrb(299.9)) + assertTrue(filters.acceptsQrb(300.0)) + assertFalse(filters.acceptsQrb(300.1)) + assertFalse(filters.acceptsQrb(null), "an unknown distance is not a short one") + } + + @Test + fun `the minimum QRB keeps only stations further away than the distance`() { + val filters = StationFilterState() + filters.minQrbEnabled = true + filters.minQrbKm = 300.0 + + assertFalse(filters.acceptsQrb(299.9)) + assertFalse(filters.acceptsQrb(300.0), "the issue asks for QRB > X, so X itself is out") + assertTrue(filters.acceptsQrb(300.1)) + assertFalse(filters.acceptsQrb(null), "an unknown distance is not a long one") + } + + @Test + fun `both QRB filters together keep a distance range`() { + val filters = StationFilterState() + filters.minQrbEnabled = true + filters.minQrbKm = 200.0 + filters.maxQrbEnabled = true + filters.maxQrbKm = 500.0 + + assertFalse(filters.acceptsQrb(200.0)) + assertTrue(filters.acceptsQrb(200.1)) + assertTrue(filters.acceptsQrb(500.0)) + assertFalse(filters.acceptsQrb(500.1)) + assertFalse(filters.acceptsQrb(null)) + } + + @Test + fun `a range with the minimum above the maximum keeps nothing`() { + val filters = StationFilterState() + filters.minQrbEnabled = true + filters.minQrbKm = 500.0 + filters.maxQrbEnabled = true + filters.maxQrbKm = 200.0 + + listOf(100.0, 300.0, 600.0).forEach { + assertFalse(filters.acceptsQrb(it), "$it km passed an empty range") + } + } + + @Test + fun `a QRB field reads plain distances`() { + assertEquals(300.0, parseQrbKm("300")) + assertEquals(12.5, parseQrbKm("12.5")) + assertEquals(0.0, parseQrbKm("0")) + } + + @Test + fun `a QRB field refuses what is no distance`() { + listOf("NaN", "Infinity", "-Infinity", "-5", "abc", "").forEach { + assertNull(parseQrbKm(it), "\"$it\" was taken as a distance") + } + } + @Test fun `a search text filters while it is there and stops when it is cleared`() { val filters = StationFilterState() 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 922aee60..0c65cc52 100644 --- a/app-desktop/src/test/kotlin/kst4contest/view/compose/StationListScreenTest.kt +++ b/app-desktop/src/test/kotlin/kst4contest/view/compose/StationListScreenTest.kt @@ -195,4 +195,30 @@ class StationListScreenTest { onNodeWithText("DL2BBB").assertDoesNotExist() assertEquals(null, 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). + */ + @Test + fun aRedrawAfterAFieldChangedFiltersTheListAgain() = runComposeUiTest { + val state = stationList() + val moving = station("DL1MOVE", qrb = 500.0) + show(state) + runOnIdle { + state.replaceRows(listOf(moving, station("DL2STAY", qrb = 800.0))) + state.rowFilter = { (it.qrb ?: 0.0) > 300.0 } + } + waitForIdle() + onNodeWithText("DL1MOVE").assertExists() + + runOnIdle { + moving.qrb = 100.0 + state.forceRedraw() + } + waitForIdle() + + onNodeWithText("DL1MOVE").assertDoesNotExist() + onNodeWithText("DL2STAY").assertExists() + } } 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 78c451e6..083ecd8c 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 @@ -4,7 +4,9 @@ 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.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 @@ -381,4 +383,91 @@ class StationMapWindowScreenTest { onNode(isToggleable()).assertIsOn() assertEquals(true, prefs.isGUIstationMapClusteringEnabled) } + + /** + * 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 + * JavaFX map took the filtered list. A filter switched on after the first draw has to + * take the marker away too, and a click where it stood selects nothing. + */ + @Test + fun aStationTheFiltersHideHasNoMarker() = runComposeUiTest { + val state = windowState(prefs()) + 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 } + + show(state, listOf(near, far), mapLookingAt("JO50JP", zoom = 12f)) + onNodeWithText("Showing 2 visible stations").assertExists() + + runOnIdle { + state.stationFilter.minQrbKm = 300.0 + state.stationFilter.minQrbEnabled = true + state.stations.rowFilter = { state.stationFilter.acceptsQrb(it.qrb) } + } + waitForIdle() + + onNodeWithText("Showing 1 visible stations | filtered view active").assertExists() + + // The map looks straight at the hidden station: a click there finds no marker. + onNodeWithTag(StationMapWindow.MAP_CANVAS_TAG).performClick() + waitForIdle() + assertEquals(emptyList(), selectorCalls, "the hidden station still had a marker") + } + + /** A station whose QRB changed in place leaves the map after the redraw, as it leaves the list. */ + @Test + fun aRedrawAfterAFieldChangedFiltersTheMapAgain() = runComposeUiTest { + val state = windowState(prefs()) + val moving = station("DL1MOVE", 2, locator = "JO50JP").apply { qrb = 500.0 } + val stay = station("DL2STAY", 2, locator = "JO62QM").apply { qrb = 800.0 } + state.stationFilter.minQrbKm = 300.0 + state.stationFilter.minQrbEnabled = true + state.stations.rowFilter = { state.stationFilter.acceptsQrb(it.qrb) } + + show(state, listOf(moving, stay), mapLookingAt("JO50JP", zoom = 12f)) + onNodeWithText("Showing 2 visible stations | filtered view active").assertExists() + + runOnIdle { + moving.qrb = 100.0 + state.stations.forceRedraw() + } + waitForIdle() + + onNodeWithText("Showing 1 visible stations | filtered view active").assertExists() + } + + /** + * A selected station the filters hide has no marker, and so nothing to analyse: no + * request goes out and the analysis does not sit at "Loading" with an empty target (#21). + * The visible station beside it is the control: selecting it does ask and does load. + */ + @Test + fun aSelectedStationTheFiltersHideGetsNoPathAnalysis() = runComposeUiTest { + val state = windowState(prefs(pathAnalysisVisible = true)) + val hidden = station("DL1HIDE", 2, locator = "JO50JP").apply { qrb = 100.0 } + val shown = station("DL2SHOW", 2, locator = "JO62QM").apply { qrb = 800.0 } + state.stationFilter.minQrbKm = 300.0 + state.stationFilter.minQrbEnabled = true + state.stations.rowFilter = { state.stationFilter.acceptsQrb(it.qrb) } + val analysis = RecordingPathAnalysis() + val mapState = mapLookingAt(ownLocator, zoom = 9f) + val home = Location(ownLocator) + + show(state, listOf(hidden, shown), mapState, analysis) + + runOnIdle { state.selectedStation.select(hidden) } + waitForIdle() + + assertEquals(emptyList(), analysis.requested, "a hidden station was analysed") + onAllNodesWithText("Loading", substring = true).assertCountEquals(0) + assertEquals(home.longitude.toDegrees(), mapState.centerLon, 1e-9, "the map panned to a hidden station") + + runOnIdle { state.selectedStation.select(shown) } + waitForIdle() + + assertEquals(listOf(shown), analysis.requested) + assertEquals(true, onAllNodesWithText("Loading", substring = true).fetchSemanticsNodes().isNotEmpty()) + } } diff --git a/github_docs/de-Benutzeroberflaeche.md b/github_docs/de-Benutzeroberflaeche.md index 8ca0765b..b9b1693d 100644 --- a/github_docs/de-Benutzeroberflaeche.md +++ b/github_docs/de-Benutzeroberflaeche.md @@ -186,6 +186,7 @@ Die Filterleiste befindet sich oberhalb der Chatmember-Tabelle. Filter können m |---|---| | **Show only QTF** | Zeigt nur Stationen innerhalb der gewählten Antennenrichtung und des konfigurierten Öffnungswinkels | | **Show only QRB [km] <=** | Begrenzt die Liste auf die eingetragene maximale Entfernung | +| **Show only QRB [km] >** | Blendet Stationen bis zur eingetragenen Mindestentfernung aus; zusammen mit dem Höchstwert ergibt sich ein Entfernungsbereich | | **Find** | Filtert nach einem vollständigen oder teilweisen Rufzeichen | | **wkd** | Blendet Basisrufzeichen aus, die bereits auf mindestens einem unterstützten Band gearbeitet wurden | | einzelne Band-Schaltflächen | Blenden Stationen aus, die auf dem betreffenden Band bereits gearbeitet oder dort als NOT QRV markiert wurden | diff --git a/github_docs/de-Changelog.md b/github_docs/de-Changelog.md index add6f53a..f37b88b2 100644 --- a/github_docs/de-Changelog.md +++ b/github_docs/de-Changelog.md @@ -24,6 +24,8 @@ Mehrere Operateure an einem Rechner können jetzt eigene Rufzeichen, Locators un - **Profilwechsel im laufenden Betrieb:** **File → Switch operator profile...** trennt die Verbindung und baut die Oberfläche mit den Einstellungen des gewählten Profils neu auf, ohne Programmneustart. +- **Filter für eine Mindestentfernung:** Neben **Show only QRB [km] <=** gibt es in der Filterleiste jetzt **Show only QRB [km] >** (deutsche Oberfläche: **Nur QRB [km] >**). Er blendet alle Stationen bis zur eingetragenen Entfernung aus. Sind beide eingeschaltet, zeigt die Liste einen Entfernungsbereich, etwa nur Stationen zwischen 300 und 800 km. Stationen ohne bekannte Entfernung werden ausgeblendet, solange einer der beiden Filter aktiv ist, und die Stationskarte folgt dem Filter. Wie beim Höchstwert gilt der Wert nur für die laufende Sitzung. Damit ist [Issue #21](https://github.com/praktimarc/kst4contest/issues/21) umgesetzt. + - **Windows-Installer:** Neben dem portablen ZIP-Paket gibt es für Windows bei Stable-Releases jetzt einen MSI-Installer (`KST4Contest-v-windows-x64.msi`). Er installiert KST4Contest ohne Administratorrechte für das aktuelle Benutzerkonto, legt einen Startmenü-Eintrag und eine Desktop-Verknüpfung an und ersetzt bei einem Update die vorhandene Installation. Einstellungen und Datenbank bleiben unter `%USERPROFILE%\.praktiKST` und werden mit einer portablen Kopie geteilt. Damit ist die erste Stufe von [Issue #61](https://github.com/praktimarc/kst4contest/issues/61) umgesetzt. ### Geändert diff --git a/github_docs/de-Funktionen.md b/github_docs/de-Funktionen.md index f28e2413..d3a54ca8 100644 --- a/github_docs/de-Funktionen.md +++ b/github_docs/de-Funktionen.md @@ -227,6 +227,8 @@ Sinnvoll: Während man CQ in eine bestimmte Richtung ruft, nur Stationen in dies Stationen jenseits einer maximalen Entfernung ausblenden. Schaltfläche **„Show only QRB [km] <="** ist ein Toggle-Button. +**„Show only QRB [km] >"** wirkt umgekehrt: Er blendet alle Stationen bis zur eingetragenen Entfernung aus, sodass nur die weiter entfernten übrig bleiben. Eine Station genau in dieser Entfernung wird ebenfalls ausgeblendet. Sind beide eingeschaltet, zeigt die Liste einen Bereich: `Minimum < QRB <= Maximum`. Solange einer der beiden Filter aktiv ist, werden Stationen ohne bekannte Entfernung ausgeblendet. Beide Werte gelten nur für die laufende Sitzung. + --- ## Filter für Worked-Status, neue Bänder und neue Großfelder diff --git a/github_docs/en-Changelog.md b/github_docs/en-Changelog.md index 3befaa45..67d426b1 100644 --- a/github_docs/en-Changelog.md +++ b/github_docs/en-Changelog.md @@ -24,6 +24,8 @@ Several operators sharing one computer can now use their own callsigns, locators - **Switching profiles while running:** **File > Switch operator profile...** closes the connection and rebuilds the user interface with the settings of the selected profile, without restarting the program. +- **Minimum distance filter:** next to **Show only QRB [km] <=** the station filter bar has **Show only QRB [km] >**, which hides every station up to the entered distance. With both switched on, the list shows a distance range, for example only stations between 300 and 800 km. Stations without a known distance are hidden while either filter is on, and the station map follows the filter. Like the maximum, the value only lasts for the running session. This implements [Issue #21](https://github.com/praktimarc/kst4contest/issues/21). + - **Windows installer:** next to the portable ZIP package, stable releases now also ship an MSI installer for Windows (`KST4Contest-v-windows-x64.msi`). It installs KST4Contest for the current user account without administrator rights, adds a Start menu entry and a desktop shortcut, and replaces the existing installation on an update. Settings and database stay under `%USERPROFILE%\.praktiKST` and are shared with a portable copy. This implements the first stage of [Issue #61](https://github.com/praktimarc/kst4contest/issues/61). ### Changed diff --git a/github_docs/en-Features.md b/github_docs/en-Features.md index 438665db..a711ed47 100644 --- a/github_docs/en-Features.md +++ b/github_docs/en-Features.md @@ -226,6 +226,8 @@ Useful: While calling CQ in a specific direction, only show stations in that dir Hide stations beyond a maximum distance. The **"Show only QRB [km] <="** button is a toggle. +**"Show only QRB [km] >"** works the other way round: it hides every station up to the entered distance, so only the far ones remain. A station exactly at that distance is hidden as well. With both switched on, the list shows a range: `minimum < QRB <= maximum`. While either filter is on, stations without a known distance are hidden. Both values only last for the running session. + --- ## Filters for Worked Status, New Bands and New Grid Squares diff --git a/github_docs/en-User-Interface.md b/github_docs/en-User-Interface.md index 7feda25b..0acb6d7c 100644 --- a/github_docs/en-User-Interface.md +++ b/github_docs/en-User-Interface.md @@ -188,6 +188,7 @@ The filter bar is located above the chat-member table. Filters can be combined; |---|---| | **Show only QTF** | Shows only stations inside the selected antenna direction and configured beamwidth | | **Show only QRB [km] <=** | Limits the list to the entered maximum distance | +| **Show only QRB [km] >** | Hides stations up to the entered minimum distance; together with the maximum it shows a distance range | | **Find** | Filters by a complete or partial callsign | | **wkd** | Hides base callsigns already worked on at least one supported band | | individual band buttons | Hide stations already worked on that band or marked NOT QRV there | diff --git a/website/src/features/station-filters.md b/website/src/features/station-filters.md index af0b88c9..cd14355d 100644 --- a/website/src/features/station-filters.md +++ b/website/src/features/station-filters.md @@ -36,6 +36,7 @@ KST4Contest therefore allows spatial, operating and propagation-related conditio - **Show only QTF** keeps stations inside the selected antenna direction and configured beamwidth. - **Show only QRB [km] <=** limits the list to the entered distance. +- **Show only QRB [km] >** hides stations up to the entered distance; with both on, the list shows a distance range. ### Operating filters