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
This commit is contained in:
Praktimarc authored and GitHub committed 2026-10-24 22:10:36 +02:00
1 parent 4642d658a3
commit a4e10300e7
114 files changed
+8065 -1128

No files matched your search

@@ -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),
)
}
@@ -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)
@@ -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<T> {
val selected: T?
/** Changes whenever [selected] changes, including a repeated explicit selection. */
val selectionRevision: Long
/** Changes only when the table should scroll to the current selection. */
val scrollRequestRevision: Long
fun select(row: T?)
/** Replaces the selected instance after a roster refresh without requesting a scroll. */
fun reconcile(row: T?)
fun requestScroll()
}
/**
* The state of a multi-column table: its rows, its sorting and its selection.
*
@@ -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<T>(
class DataTableState<T> @JvmOverloads constructor(
val columns: List<DataColumn<T>>,
private val rowKey: RowKeySource<T>,
/** 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<T>? = null,
private val keepSelectionVisible: Boolean = false,
) {
/**
@@ -251,19 +277,29 @@ class DataTableState<T>(
var rows: List<T> 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<T>? = null
private var internalSelectionRevision: Long by mutableStateOf(0L)
private var internalScrollRequestRevision: Long by mutableStateOf(0L)
private var filteredCache: List<T>? = null
private var visibleCache: List<T>? = null
private var cachedSelectionRevision: Long = Long.MIN_VALUE
private var _rowFilter by mutableStateOf<((T) -> Boolean)?>(null, androidx.compose.runtime.neverEqualPolicy())
/** 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<T>(
* 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<T>
val filteredRows: List<T>
get() {
/*
* Read before the cache is consulted, and that order is the whole point.
@@ -307,45 +343,84 @@ class DataTableState<T>(
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<T>
get() {
val currentRows = rows
val filtered = filteredRows
val revision = selectionRevision
if (!keepSelectionVisible) {
return filtered
}
if (visibleCache != null && cachedSelectionRevision == revision) {
return visibleCache.orEmpty()
}
val selected = selectedRow
val visible = if (selected == null || filtered.any { keyOf(it) == keyOf(selected) }) {
filtered
} else if (currentRows.any { keyOf(it) == keyOf(selected) }) {
val filteredKeys = filtered.mapTo(HashSet<Any>()) { row -> keyOf(row) }
val selectedKey = keyOf(selected)
sort(currentRows.filter { row -> keyOf(row) == selectedKey || keyOf(row) in filteredKeys })
} else {
filtered
}
cachedSelectionRevision = revision
visibleCache = visible
return visible
}
private fun sort(
source: List<T>,
columnId: String? = sortedColumnId,
ascending: Boolean = sortAscending,
): List<T> {
val column = columns.firstOrNull { it.id == columnId } ?: return source
/*
* The displayed text is read once per row rather than once per comparison, and
* Kotlin's sort is stable, so equal values keep their source order.
*/
val order = column.comparator
?: compareBy(source.associateWith { column.value(it) }::getValue)
return if (ascending) source.sortedWith(order) else source.sortedWith(order.reversed())
}
private fun invalidateOrder() {
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<T>(
* neighbour, which would silently point the operator at someone else.
*/
fun replaceRows(newRows: List<T>) {
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<T>(
*/
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.
@@ -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 <T> DataTableView(
state: DataTableState<T>,
modifier: Modifier = Modifier,
rowStyle: ((T) -> CellAccent?)? = null,
emphasizeCell: ((T, DataColumn<T>) -> 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 <T> DataTableView(
}
}
var handledScrollRequest by remember(state) { mutableStateOf(0L) }
val scrollRequest = state.scrollRequestRevision
val displayedRows = state.visibleRows
/*
* Resolve the index only after reading the current filtered and sorted rows. If the
* selected row has not arrived yet, leave the request pending and retry when that
* list changes. A handled request is never repeated by an ordinary data refresh.
*/
LaunchedEffect(scrollRequest, displayedRows) {
if (scrollRequest <= handledScrollRequest) return@LaunchedEffect
val index = displayedRows.indexOfFirst(state::isSelected)
if (index >= 0) {
vertical.scrollToItem(index)
handledScrollRequest = scrollRequest
}
}
/*
* Both directions scroll. The columns of these two tables need about 890dp and
* 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 <T> 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 <T> 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 <T> 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 <T> 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 <T> 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 <T> DataTableView(
val accent = rowStyle?.invoke(row)
val isEven = index % 2 == 0
val _forceRedraw = state.contentRevision
var rowCoordinates by remember(state.keyOf(row)) {
mutableStateOf<LayoutCoordinates?>(null)
}
Column(modifier = Modifier.fillMaxWidth()) {
Row(
@@ -306,9 +349,25 @@ fun <T> 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 <T> 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 <T> 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 <T> DataTableView(
}
}
if (state.visibleRows.isEmpty()) {
Text(
text = LocalStrings.current.tableEmpty,
modifier = Modifier.align(Alignment.Center).padding(8.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline,
)
}
VerticalScrollbar(
adapter = rememberScrollbarAdapter(vertical),
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
@@ -402,39 +475,71 @@ fun <T> 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 <T> Modifier.columnWidth(
state: DataTableState<T>,
column: DataColumn<T>,
extra: Dp = 0.dp,
automaticWidths: Map<String, Float>,
): 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 <T> unusedWidth(state: DataTableState<T>, 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 <T> rememberAutomaticColumnWidths(state: DataTableState<T>): Map<String, Float> {
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 <T> effectiveColumnWidthDp(
state: DataTableState<T>,
column: DataColumn<T>,
automaticWidths: Map<String, Float>,
): Float = state.widthOf(column.id)?.toFloat()
?: automaticWidths[column.id]
?: DEFAULT_COLUMN_WIDTH_DP * column.weight
private const val AUTO_WIDTH_SAMPLE_SIZE = 160
private const val AUTO_WIDTH_MIN_DP = 32f
private const val AUTO_WIDTH_MIN_MAX_DP = 90f
private const val AUTO_WIDTH_MAX_BASE_DP = 180f
private const val AUTO_WIDTH_ABSOLUTE_MAX_DP = 360f
private const val CELL_HORIZONTAL_PADDING_DP = 10f
private const val DEFAULT_COLUMN_WIDTH_DP = 85f
private const val SORT_INDICATOR_SAMPLE = " ▲"
@@ -13,27 +13,36 @@ import androidx.compose.ui.unit.dp
* over thirty fields, so those defaults turn a settings window into a scrolling
* 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
@@ -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),
)
}
}
@@ -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<Band>,
openInBrowser: Consumer<String>,
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<Band>,
openInBrowser: Consumer<String>,
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)
}
@@ -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<MainWindowState?>(null)
private var currentActions by androidx.compose.runtime.mutableStateOf<MainMenuActions?>(null)
private var currentSkedBands by androidx.compose.runtime.mutableStateOf<(() -> List<Band>)?>(null)
private var currentOpenInBrowser by androidx.compose.runtime.mutableStateOf<Consumer<String>?>(null)
private var 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<Band>,
openInBrowser: Consumer<String>,
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 ?: {},
@@ -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<String>) = { emptySet() }
val stationSelection: StationSelectionState
get() = selectedStation.stationSelection
/** Routes every explicit station choice through the application-level action. */
fun selectStation(member: ChatMember?, source: StationSelectionSource) {
mapReachability.selectionChanged(StationIdentity.of(member))
val selector = stationSelector
if (selector != null) {
selector(member, source)
} else {
selectedStation.select(member, source)
}
}
fun resetStationFilters() {
stationFilter.reset()
stationFiltersChanged?.invoke()
}
/** Applies only a deliberate QTF filter entry, never a restored or reset filter value. */
fun applyExplicitQtfFilterDirection(qtf: Double) {
applyExplicitQtfHeading(prefs, qtf)
}
fun requestLayoutSave() {
layoutSaveRequested?.invoke()
}
val shortcuts = RosterMirror(prefs.getLst_txtShortCutBtnList())
@@ -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
}
}
@@ -0,0 +1,19 @@
package kst4contest.view.compose
import kst4contest.model.ChatPreferences
private const val MIN_QTF = 0.0
private const val MAX_QTF = 360.0
/**
* Applies a deliberate filter direction to the runtime antenna heading without sending a rotor command.
*
* @return true when the runtime heading was updated
*/
internal fun applyExplicitQtfHeading(prefs: ChatPreferences, qtf: Double): Boolean {
if (!qtf.isFinite() || qtf !in MIN_QTF..MAX_QTF) return false
if (!prefs.isQtfFilterDirectionAsAntennaHeadingEnabled) return false
prefs.actualQTF.set(qtf)
return true
}
@@ -0,0 +1,83 @@
package kst4contest.view.compose
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kst4contest.model.Band
import kst4contest.model.ChatMember
import kst4contest.view.map.PathAnalysisResult
/**
* Explicit map reachability request shared by station-list and map context menus.
* A monotonically increasing request ID prevents older callbacks from replacing a
* result for a newer station or band.
*/
class MapReachabilityState(
private val ownLocator: String,
requester: (ChatMember, Band, (PathAnalysisResult) -> Unit) -> Unit = { _, _, _ -> },
) {
var requester: (ChatMember, Band, (PathAnalysisResult) -> Unit) -> Unit = requester
var result: PathAnalysisResult? by mutableStateOf(null)
private set
var running: Boolean by mutableStateOf(false)
private set
var requestRevision: Long by mutableStateOf(0L)
private set
var targetIdentity: StationIdentity? by mutableStateOf(null)
private set
var requestedBand: Band? by mutableStateOf(null)
private set
fun calculate(member: ChatMember?, band: Band?): Boolean {
if (member == null || band == null) return false
val identity = StationIdentity.of(member) ?: return false
val requestId = ++requestRevision
targetIdentity = identity
requestedBand = band
running = true
result = PathAnalysisResult.loading(ownLocator, member.qra, member.callSignRaw)
try {
requester(member, band) { completed ->
if (requestRevision == requestId
&& targetIdentity == identity
&& requestedBand == band
) {
result = completed
running = false
}
}
} catch (exception: RuntimeException) {
if (requestRevision == requestId) {
result = PathAnalysisResult.noProfile(
"Failed",
ownLocator,
member.qra,
member.callSignRaw,
Double.NaN,
member.getQTFdirection() ?: Double.NaN,
Double.NaN,
Double.NaN,
band.defaultAnalysisFrequencyMHz,
exception.message ?: "Reachability calculation failed.",
)
running = false
}
}
return true
}
fun selectionChanged(identity: StationIdentity?) {
if (targetIdentity == null || targetIdentity == identity) return
requestRevision++
targetIdentity = null
requestedBand = null
result = null
running = false
}
}
@@ -2,13 +2,11 @@ package kst4contest.view.compose
import kst4contest.view.i18n.LocalStrings
import 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<ChatMessage>,
clusterMessages: DataTableState<ClusterMessage>,
qsoOfTheOther: DataTableState<ChatMessage>,
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)
},
)
}
}
}
@@ -0,0 +1,100 @@
package kst4contest.view.compose
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kst4contest.model.ChatMember
import kst4contest.model.RecentChatJoin
/** One selectable station in the transient New joined group. */
class NewJoinedEntry internal constructor(
val identity: StationIdentity,
val label: String,
val joinedAtEpochMillis: Long,
private val member: ChatMember,
private val onSelect: (ChatMember) -> Unit,
) {
fun select() = onSelect(member)
}
/**
* Compose-facing view of live ON4KST join events.
*
* Initial and resynchronized user lists never reach this state. It receives the core's
* live-event roster and intersects it with the currently active station roster, so a
* departed station disappears immediately. The clock is injected to make expiry exact
* and testable without waiting ten minutes.
*/
class NewJoinedState(
private val clock: () -> Long = System::currentTimeMillis,
private val onSelect: (ChatMember) -> Unit,
) {
private var events: List<RecentChatJoin> = emptyList()
private var activeMembers: List<ChatMember> = emptyList()
private var liveEntries by mutableStateOf<List<NewJoinedEntry>>(emptyList())
val entries: List<NewJoinedEntry>
get() = liveEntries
val visibleEntries: List<NewJoinedEntry>
get() = liveEntries.take(SHOWN)
val overflowEntries: List<NewJoinedEntry>
get() = liveEntries.drop(SHOWN)
fun updateEvents(newEvents: List<RecentChatJoin>) {
events = newEvents.toList()
refresh()
}
fun updateActiveMembers(newMembers: List<ChatMember>) {
activeMembers = newMembers.toList()
refresh()
}
/** Removes expired or departed entries and resolves each event to the latest row instance. */
fun refresh() {
val now = clock()
val activeByIdentity = activeMembers.mapNotNull { member ->
StationIdentity.of(member)?.let { identity -> identity to member }
}.toMap()
val firstJoinByIdentity = linkedMapOf<StationIdentity, RecentChatJoin>()
events.forEach { event ->
val identity = StationIdentity.of(event.callSign, event.categoryNumber)
?: return@forEach
val existing = firstJoinByIdentity[identity]
if (existing == null || event.joinedAtEpochMillis < existing.joinedAtEpochMillis) {
firstJoinByIdentity[identity] = event
}
}
liveEntries = firstJoinByIdentity
.mapNotNull { (identity, event) ->
val active = activeByIdentity[identity] ?: return@mapNotNull null
val ageMillis = now - event.joinedAtEpochMillis
if (ageMillis < 0L || ageMillis >= VALIDITY_MILLIS) {
return@mapNotNull null
}
NewJoinedEntry(
identity = identity,
label = identity.callSign,
joinedAtEpochMillis = event.joinedAtEpochMillis,
member = active,
onSelect = onSelect,
)
}
.sortedByDescending(NewJoinedEntry::joinedAtEpochMillis)
}
fun isRecent(member: ChatMember): Boolean {
val identity = StationIdentity.of(member) ?: return false
return liveEntries.any { it.identity == identity }
}
companion object {
const val VALIDITY_MILLIS = 10L * 60L * 1000L
const val SHOWN = 5
}
}
@@ -0,0 +1,58 @@
package kst4contest.view.compose
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kst4contest.model.Band
import kst4contest.model.ChatMember
/** One selectable entry in the reachability-band control. A null band means Auto. */
data class ReachabilityBandOption(val band: Band?, val label: String)
/**
* Operator-controlled reachability selection and the one explicit calculation action.
*
* Band selection only invalidates cached table/filter presentation. Online terrain work
* stays behind [calculateSelected], so recomposition and sorting cannot consume API calls.
*/
class ReachabilityState(
activeBands: Set<Band>,
private val resolveAutoBand: (ChatMember) -> Band? = { null },
private val selectedStation: () -> ChatMember? = { null },
private val calculate: (ChatMember, Band) -> Unit = { _, _ -> },
private val refresh: () -> Unit = { },
) {
private val supportedBands = activeBands.toSet()
val options: List<ReachabilityBandOption> =
listOf(ReachabilityBandOption(null, "Auto")) +
activeBands.sortedBy(Band::ordinal).map { band ->
ReachabilityBandOption(band, band.displayLabel)
}
var bandOverride: Band? by mutableStateOf(null)
private set
val selectedOption: ReachabilityBandOption
get() = options.first { it.band == bandOverride }
val canCalculate: Boolean
get() = selectedStation()?.let(::resolveBand) != null
fun selectBand(band: Band?) {
val accepted = band?.takeIf(supportedBands::contains)
if (bandOverride == accepted) return
bandOverride = accepted
refresh()
}
fun resolveBand(member: ChatMember): Band? = bandOverride ?: resolveAutoBand(member)
/** Returns whether a complete station/band request was handed to the service. */
fun calculateSelected(): Boolean {
val member = selectedStation() ?: return false
val band = resolveBand(member) ?: return false
calculate(member, band)
return true
}
}
@@ -0,0 +1,60 @@
package kst4contest.view.compose
import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage
import java.util.Locale
import java.util.function.Predicate
/** Builds the selected-station message filter without changing the source history. */
object SelectedStationMessagePredicate {
@JvmStatic
fun forSelection(
filter: SelectedMessageFilter,
selected: ChatMember?,
ownCallSign: String?,
): Predicate<ChatMessage> {
val selectedIdentity = StationIdentity.of(selected)
val ownCall = ownCallSign.normalizedCall()
return Predicate { message ->
if (selectedIdentity?.categoryNumber == null) {
return@Predicate false
}
val senderCall = message?.sender?.callSign.normalizedCall()
val receiverCall = message?.receiver?.callSign.normalizedCall()
val messageCategory = message?.chatCategory?.categoryNumber
if (senderCall.isBlank() || receiverCall.isBlank() ||
messageCategory != selectedIdentity.categoryNumber
) {
return@Predicate false
}
val selectedIsSender = senderCall == selectedIdentity.callSign
val selectedIsReceiver = receiverCall == selectedIdentity.callSign
val ownIsSender = senderCall == ownCall
val ownIsReceiver = receiverCall == ownCall
val isPublic = message.isMessageDirectedToCommunity || receiverCall == PUBLIC_RECEIVER
when (filter) {
SelectedMessageFilter.PM_TO_ME ->
ownCall.isNotBlank() && (
(!isPublic && ((selectedIsSender && ownIsReceiver) ||
(ownIsSender && selectedIsReceiver))) ||
(isPublic && selectedIsSender && message.isMessageDirectedToMe)
)
SelectedMessageFilter.PM_TO_OTHER ->
ownCall.isNotBlank() && selectedIsSender && !isPublic && !ownIsReceiver
SelectedMessageFilter.PUBLIC -> selectedIsSender && isPublic
SelectedMessageFilter.NOTHING -> selectedIsSender || selectedIsReceiver
}
}
}
private fun String?.normalizedCall(): String = this?.trim()?.uppercase(Locale.ROOT).orEmpty()
private const val PUBLIC_RECEIVER = "ALL"
}
@@ -2,15 +2,36 @@ package kst4contest.view.compose
import kst4contest.view.i18n.LocalStrings
import 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<Band>): List<Band> {
val options = ArrayList<Band>(activeBands.size)
Band.entries.forEach { band ->
if (activeBands.contains(band)) options.add(band)
}
return options
}
/**
* The panel below the station list, for whichever station is selected.
@@ -40,10 +79,9 @@ import java.util.function.Consumer
fun SelectedStationPanel(
state: SelectedStationState,
messages: DataTableState<kst4contest.model.ChatMessage>,
/** The bands this station is set up for; BandOpportunityResolver decides them. */
skedBands: List<Band>,
/** 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<Band>,
contentSplitter: SplitterState? = null,
openInBrowser: Consumer<String>,
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<Band>,
openInBrowser: Consumer<String>,
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<Band>,
) {
val strings = LocalStrings.current
val member = state.selected ?: return
val activeNotQrvBands = NotQrvBand.entries.filter { activeBands.contains(it.band) }
val notQrvContent: @Composable () -> Unit = {
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
) {
activeNotQrvBands.forEach { band ->
Form.toggle(band.label, state.isTaggedNotQrv(band)) {
state.tagNotQrv(band, it)
}
}
Form.toggle(
strings.selectedNotQrvAll,
activeNotQrvBands.all { state.isTaggedNotQrv(it) },
) {
state.tagNotQrvAll(it)
}
}
}
val skedContent: @Composable () -> Unit = {
SkedAlertControls(state, activeBands)
}
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
if (maxWidth >= Density.SELECTED_ACTION_GROUPS_ROW_MIN_WIDTH) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
verticalAlignment = Alignment.Top,
) {
CompactFunctionGroup(
title = strings.selectedTagNotQrv(member.callSign.orEmpty()),
modifier = Modifier.weight(0.35f).testTag(SELECTED_NOT_QRV_GROUP_TAG),
content = notQrvContent,
)
CompactFunctionGroup(
title = strings.selectedSked,
modifier = Modifier.weight(0.65f).testTag(SELECTED_SKED_GROUP_TAG),
content = skedContent,
)
}
} else {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
) {
CompactFunctionGroup(
title = strings.selectedTagNotQrv(member.callSign.orEmpty()),
modifier = Modifier.fillMaxWidth().testTag(SELECTED_NOT_QRV_GROUP_TAG),
content = notQrvContent,
)
CompactFunctionGroup(
title = strings.selectedSked,
modifier = Modifier.fillMaxWidth().testTag(SELECTED_SKED_GROUP_TAG),
content = skedContent,
)
}
}
}
}
@Composable
private fun CompactFunctionGroup(
title: String,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
) {
Column(
modifier = modifier
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.48f))
.border(Density.HAIRLINE, MaterialTheme.colorScheme.outlineVariant, Density.BUTTON_SHAPE)
.padding(3.dp),
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
) {
Text(
title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
)
content()
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun SkedAlertControls(state: SelectedStationState, activeBands: Set<Band>) {
val strings = LocalStrings.current
SkedEditorControls(
activeBands = activeBands,
minutes = state.skedMinutes,
onMinutesChanged = { state.skedMinutes = it },
band = state.skedBand,
onBandChanged = { state.skedBand = it },
mode = state.skedMode,
onModeChanged = { state.skedMode = it },
remindPm = state.remindPm,
onRemindPmChanged = { state.remindPm = it },
reminderOffsets = state.reminderOffsets,
onReminderOffsetsChanged = { state.reminderOffsets = it },
canCreate = state.canCreateSked,
onCreate = { state.createSked() },
additionalActions = {
Form.button(strings.selectedSkedFail, enabled = state.canCreateSked) { state.markSkedFail() }
Form.button(strings.selectedResetFail, enabled = state.canCreateSked) { state.resetSkedFail() }
},
)
}
/** Shared compact sked editor used by the station panel and the context popup. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
internal fun SkedEditorControls(
activeBands: Set<Band>,
minutes: Int,
onMinutesChanged: (Int) -> Unit,
band: Band?,
onBandChanged: (Band) -> Unit,
mode: String?,
onModeChanged: (String) -> Unit,
remindPm: Boolean,
onRemindPmChanged: (Boolean) -> Unit,
reminderOffsets: String,
onReminderOffsetsChanged: (String) -> Unit,
canCreate: Boolean,
onCreate: () -> Unit,
additionalActions: @Composable () -> Unit = {},
) {
val strings = LocalStrings.current
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
) {
Form.inlineChoice(
label = strings.selectedSkedIn,
items = SelectedStationState.SKED_MINUTES,
selected = minutes,
describe = { it.toString() },
trailingText = strings.selectedSkedMinutesAt,
onSelect = onMinutesChanged,
)
Form.inlineChoice(
label = "",
items = skedBandOptions(activeBands),
selected = band,
describe = { it.prefix + " MHz" },
trailingText = strings.selectedSkedModeLink,
onSelect = onBandChanged,
)
Form.inlineChoice(
label = "",
items = SelectedStationState.SKED_MODES,
selected = mode,
describe = { it },
trailingText = ".",
onSelect = onModeChanged,
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.height(Density.CONTROL_HEIGHT),
) {
CompactCheckbox(remindPm, onCheckedChange = onRemindPmChanged)
Text(
strings.selectedRemindPmIn,
style = compactControlTextStyle(),
modifier = Modifier.padding(start = 4.dp, end = 4.dp),
)
Form.inlineChoice(
label = "",
items = SelectedStationState.REMINDER_OFFSETS,
selected = reminderOffsets,
describe = { it },
enabled = remindPm,
trailingText = strings.selectedMinutes,
onSelect = onReminderOffsetsChanged,
)
}
}
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(Density.BUTTON_GAP),
verticalArrangement = Arrangement.spacedBy(Density.FIELD_GAP),
) {
Form.button(strings.selectedCreateSked, enabled = canCreate, onClick = onCreate)
additionalActions()
}
}
@@ -214,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<NewJoinedEntry>) {
val strings = LocalStrings.current
var expanded by remember { mutableStateOf(false) }
Box {
Form.button(
strings.selectedMore,
modifier = Modifier.testTag(NEW_JOINED_MORE_TAG),
horizontalPadding = 4.dp,
) { expanded = true }
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
entries.forEach { entry ->
DropdownMenuItem(
text = { Text(entry.label, style = compactControlTextStyle()) },
onClick = {
expanded = false
entry.select()
},
contentPadding = PaddingValues(horizontal = 8.dp),
modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT),
)
}
}
}
}
}
private const val NEW_JOINED_REFRESH_INTERVAL_MILLIS = 1_000L
/**
* 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,
) {
@@ -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.
*
@@ -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++
},
)
}
@@ -0,0 +1,50 @@
package kst4contest.view.compose
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kst4contest.model.Band
import kst4contest.model.ChatMember
/** Complete immutable request handed to the shared sked creation action. */
data class SkedRequest(
val target: ChatMember,
val minutes: Int,
val band: Band?,
val mode: String?,
val remindPm: Boolean,
val reminderOffsets: String,
)
/**
* Isolated editor draft used by the station-context popup.
*
* The target is captured when the popup opens. Later station selections therefore do
* not redirect a partially edited sked.
*/
class SkedEditorState(
val target: ChatMember,
minutes: Int,
band: Band?,
mode: String?,
remindPm: Boolean,
reminderOffsets: String,
private val submitter: (SkedRequest) -> Boolean,
) {
var minutes by mutableStateOf(minutes)
var band by mutableStateOf(band)
var mode by mutableStateOf(mode)
var remindPm by mutableStateOf(remindPm)
var reminderOffsets by mutableStateOf(reminderOffsets)
fun request(): SkedRequest = SkedRequest(
target = target,
minutes = minutes,
band = band,
mode = mode,
remindPm = remindPm,
reminderOffsets = reminderOffsets,
)
fun submit(): Boolean = submitter(request())
}
@@ -18,6 +18,7 @@ import androidx.compose.ui.input.pointer.pointerHoverIcon
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.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<Dp> = 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,
@@ -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<Float> {
fun paneSizes(totalPx: Float, minimumPaneSizesPx: FloatArray = EMPTY_MINIMUMS): List<Float> {
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
@@ -45,7 +45,14 @@ object StationColumns {
formatBand: (ChatMember, kst4contest.model.Band, Boolean) -> String,
activeBands: EnumSet<kst4contest.model.Band> = EnumSet.allOf(kst4contest.model.Band::class.java),
): List<DataColumn<ChatMember>> = buildList {
add(DataColumn(id = "callsign", title = { kst4contest.view.i18n.CurrentStrings.get().columnCallsign }, value = { it.callSign.orEmpty() }))
add(DataColumn(
id = "callsign",
title = { kst4contest.view.i18n.CurrentStrings.get().columnCallsign },
value = { it.callSign.orEmpty() },
comparator = compareBy(String.CASE_INSENSITIVE_ORDER) { member: ChatMember ->
member.callSign?.trim().orEmpty()
},
))
add(DataColumn(id = "name", title = { kst4contest.view.i18n.CurrentStrings.get().columnName }, value = { it.name.orEmpty() }))
add(DataColumn(id = "qra", title = "QRA", value = { it.qra.orEmpty() }, weight = 0.8f))
add(DataColumn(
@@ -0,0 +1,314 @@
package kst4contest.view.compose
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MenuDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import kst4contest.model.Band
import kst4contest.model.ChatMember
import kst4contest.view.i18n.LocalStrings
internal const val STATION_CONTEXT_MENU_TAG = "station-context-menu"
internal const val STATION_CONTEXT_TARGET_CHOOSER_TAG = "station-context-target-chooser"
internal const val STATION_SKED_POPUP_TAG = "station-sked-popup"
private enum class StationContextPage {
ROOT,
SNIPPETS,
NOT_QRV,
WORKED,
REACHABILITY,
}
/** Shared station action popup used by the user list and the map. */
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StationContextOverlays(
menuState: StationContextMenuState,
mainWindowState: MainWindowState,
onShowMap: (() -> Unit)? = null,
) {
val strings = LocalStrings.current
var page by remember { mutableStateOf(StationContextPage.ROOT) }
var skedDraft by remember { mutableStateOf<SkedEditorState?>(null) }
var skedPosition by remember { mutableStateOf(IntOffset.Zero) }
LaunchedEffect(menuState.candidates, menuState.target) {
page = StationContextPage.ROOT
}
if (menuState.isOpen) {
Popup(
popupPositionProvider = WindowPointPopupPositionProvider(menuState.position),
onDismissRequest = menuState::close,
properties = PopupProperties(focusable = true),
) {
Surface(
modifier = Modifier.testTag(STATION_CONTEXT_MENU_TAG),
shape = MenuDefaults.shape,
color = MenuDefaults.containerColor,
tonalElevation = MenuDefaults.TonalElevation,
shadowElevation = MenuDefaults.ShadowElevation,
) {
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) {
Column(
modifier = Modifier
.widthIn(min = 190.dp, max = 360.dp)
.verticalScroll(rememberScrollState())
.padding(vertical = 4.dp),
) {
val target = menuState.target
if (target == null) {
ContextHeading(
strings.contextChooseStation,
Modifier.testTag(STATION_CONTEXT_TARGET_CHOOSER_TAG),
)
menuState.candidates.forEach { candidate ->
ContextItem(stationContextTargetLabel(candidate)) {
menuState.chooseTarget(candidate)
}
}
} else {
when (page) {
StationContextPage.ROOT -> {
ContextSubmenu(strings.contextMessageSnippets) {
page = StationContextPage.SNIPPETS
}
ContextItem(strings.contextCreateSked) {
skedPosition = menuState.position
skedDraft = mainWindowState.selectedStation.createSkedDraft(target)
menuState.close()
}
ContextSeparator()
ContextSubmenu(strings.contextTagNotQrv) {
page = StationContextPage.NOT_QRV
}
ContextSubmenu(strings.contextMarkWorked) {
page = StationContextPage.WORKED
}
ContextSeparator()
ContextSubmenu(strings.contextCalculateReachability) {
page = StationContextPage.REACHABILITY
}
}
StationContextPage.SNIPPETS -> {
ContextBack { page = StationContextPage.ROOT }
mainWindowState.snippets.entries.forEach { snippet ->
ContextItem(snippet) {
mainWindowState.chatInput.appendSnippet(snippet)
menuState.close()
}
}
}
StationContextPage.NOT_QRV -> {
ContextBack { page = StationContextPage.ROOT }
NotQrvBand.entries
.filter { it.band in mainWindowState.activeBands }
.forEach { band ->
val tagged = mainWindowState.selectedStation
.isTaggedNotQrv(target, band)
ContextCheckItem(band.label, tagged) {
mainWindowState.selectedStation
.tagNotQrv(target, band, !tagged)
}
}
}
StationContextPage.WORKED -> {
ContextBack { page = StationContextPage.ROOT }
mainWindowState.activeBands.sortedBy(Band::ordinal).forEach { band ->
val worked = target.isWorkedOn(band)
ContextCheckItem(band.displayLabel, worked, enabled = !worked) {
if (mainWindowState.markStationWorked?.invoke(target, band) == true) {
menuState.close()
}
}
}
}
StationContextPage.REACHABILITY -> {
ContextBack { page = StationContextPage.ROOT }
mainWindowState.activeBands.sortedBy(Band::ordinal).forEach { band ->
ContextItem(band.displayLabel) {
if (mainWindowState.mapReachability.calculate(target, band)) {
onShowMap?.invoke()
menuState.close()
}
}
}
}
}
}
}
}
}
}
}
skedDraft?.let { draft ->
Popup(
popupPositionProvider = WindowPointPopupPositionProvider(skedPosition),
onDismissRequest = { skedDraft = null },
properties = PopupProperties(focusable = true),
) {
Surface(
modifier = Modifier
.widthIn(min = 420.dp, max = 560.dp)
.testTag(STATION_SKED_POPUP_TAG),
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.surface,
tonalElevation = 4.dp,
shadowElevation = 8.dp,
) {
Column(
modifier = Modifier.padding(8.dp),
) {
Text(
strings.contextSkedTitle(draft.target.callSign.orEmpty()),
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(bottom = 4.dp),
)
SkedEditorControls(
activeBands = mainWindowState.activeBands,
minutes = draft.minutes,
onMinutesChanged = { draft.minutes = it },
band = draft.band,
onBandChanged = { draft.band = it },
mode = draft.mode,
onModeChanged = { draft.mode = it },
remindPm = draft.remindPm,
onRemindPmChanged = { draft.remindPm = it },
reminderOffsets = draft.reminderOffsets,
onReminderOffsetsChanged = { draft.reminderOffsets = it },
canCreate = draft.band != null,
onCreate = {
if (draft.submit()) skedDraft = null
},
additionalActions = {
Form.button(strings.contextCancel) { skedDraft = null }
},
)
}
}
}
}
}
@Composable
private fun ContextHeading(text: String, modifier: Modifier = Modifier) {
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
modifier = modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
)
}
@Composable
private fun ContextBack(onClick: () -> Unit) {
val strings = LocalStrings.current
ContextItem("‹ ${strings.contextBack}", onClick = onClick)
ContextSeparator()
}
@Composable
private fun ContextSubmenu(text: String, onClick: () -> Unit) {
ContextItem("$text ›", onClick = onClick)
}
@Composable
private fun ContextCheckItem(
text: String,
checked: Boolean,
enabled: Boolean = true,
onClick: () -> Unit,
) {
ContextItem((if (checked) "✓ " else " ") + text, enabled, onClick)
}
@Composable
private fun ContextItem(text: String, enabled: Boolean = true, onClick: () -> Unit) {
DropdownMenuItem(
text = { Text(text, style = compactControlTextStyle(), maxLines = 1) },
enabled = enabled,
onClick = onClick,
contentPadding = PaddingValues(horizontal = 8.dp),
modifier = Modifier.heightIn(min = Density.CONTROL_HEIGHT),
)
}
@Composable
private fun ContextSeparator() {
HorizontalDivider(
thickness = Density.HAIRLINE,
color = MaterialTheme.colorScheme.outlineVariant,
)
}
internal fun stationContextTargetLabel(member: ChatMember): String {
val category = member.chatCategory
val categoryName = category?.getChatCategoryName(category.categoryNumber)
?.takeIf { it.isNotBlank() }
?: category?.categoryNumber?.toString().orEmpty()
return if (categoryName.isBlank()) member.callSign.orEmpty()
else "${member.callSign.orEmpty()} | $categoryName"
}
internal fun ChatMember.isWorkedOn(band: Band): Boolean = when (band) {
Band.B_50 -> isWorked50
Band.B_70 -> isWorked70
Band.B_144 -> isWorked144
Band.B_432 -> isWorked432
Band.B_1296 -> isWorked1240
Band.B_2320 -> isWorked2300
Band.B_3400 -> isWorked3400
Band.B_5760 -> isWorked5600
Band.B_10G -> isWorked10G
Band.B_24G -> isWorked24G
}
/** Window-relative popup position clamped to the visible window on every DPI scale. */
internal data class WindowPointPopupPositionProvider(
val point: IntOffset,
) : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize,
): IntOffset = IntOffset(
x = point.x.coerceIn(0, maxOf(0, windowSize.width - popupContentSize.width)),
y = point.y.coerceIn(0, maxOf(0, windowSize.height - popupContentSize.height)),
)
}
@@ -0,0 +1,59 @@
package kst4contest.view.compose
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.unit.IntOffset
import kst4contest.model.ChatMember
/** Current target and window-relative anchor of a station context menu. */
class StationContextMenuState(
private val selectStation: (ChatMember, StationSelectionSource) -> Unit,
) {
var position: IntOffset by mutableStateOf(IntOffset.Zero)
private set
var candidates: List<ChatMember> by mutableStateOf(emptyList())
private set
var target: ChatMember? by mutableStateOf(null)
private set
var source: StationSelectionSource by mutableStateOf(StationSelectionSource.PROGRAMMATIC)
private set
val isOpen: Boolean
get() = candidates.isNotEmpty()
fun open(
members: List<ChatMember>,
source: StationSelectionSource,
position: IntOffset,
) {
val unique = members
.filter { StationIdentity.of(it) != null }
.distinctBy(StationIdentity::of)
if (unique.isEmpty()) {
close()
return
}
this.position = position
this.source = source
candidates = unique
target = unique.singleOrNull()
target?.let { selectStation(it, source) }
}
fun chooseTarget(member: ChatMember) {
val identity = StationIdentity.of(member) ?: return
val resolved = candidates.firstOrNull { StationIdentity.of(it) == identity } ?: return
target = resolved
selectStation(resolved, source)
}
fun close() {
candidates = emptyList()
target = null
}
}
@@ -3,49 +3,53 @@ package kst4contest.view.compose
import kst4contest.view.i18n.LocalStrings
import 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<kst4contest.model.Band>,
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()
}
}
@@ -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),
@@ -0,0 +1,135 @@
package kst4contest.view.compose
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage
import java.util.Locale
/** Full ON4KST station identity used across tables, messages and the map. */
data class StationIdentity(
val callSign: String,
val categoryNumber: Int?,
) {
companion object {
@JvmStatic
fun of(member: ChatMember?): StationIdentity? =
of(member?.callSign, member?.chatCategory?.categoryNumber)
@JvmStatic
fun of(callSignValue: String?, categoryNumber: Int?): StationIdentity? {
val callSign = callSignValue?.trim()?.uppercase(Locale.ROOT).orEmpty()
if (callSign.isBlank()) return null
return StationIdentity(callSign, categoryNumber)
}
}
}
/** Resolves the station navigated to by a directed-message row click. */
object DirectedMessageStationTarget {
@JvmStatic
fun forMessage(message: ChatMessage?, ownCallSign: String?): StationIdentity? {
val senderCallSign = message?.sender?.callSign ?: return null
val ownCall = ownCallSign?.trim()?.uppercase(Locale.ROOT).orEmpty()
val senderCall = senderCallSign.trim().uppercase(Locale.ROOT)
val targetCall = if (senderCall == ownCall) {
message.receiver?.callSign ?: receiverFromPayload(message.messageText)
} else {
senderCallSign
}
return StationIdentity.of(targetCall, message.chatCategory?.categoryNumber)
}
private fun receiverFromPayload(messageText: String?): String? {
if (messageText == null || !messageText.startsWith('(')) return null
val closing = messageText.indexOf(')')
return if (closing > 1) messageText.substring(1, closing).trim() else null
}
}
/** Where an explicit station selection came from. */
enum class StationSelectionSource {
STATION_TABLE,
STATION_CONTEXT_MENU,
DIRECTED_MESSAGE,
PUBLIC_MESSAGE,
QSO_OF_THE_OTHER,
MAP,
MAP_CONTEXT_MENU,
PRIORITY,
NEW_JOINED,
TIMELINE,
RESET_VIEW,
PROGRAMMATIC,
}
/**
* Canonical station selection shared by the station table and detail views.
*
* Data refreshes reconcile the selected instance without moving the table. Explicit
* user navigation increments [scrollRequestRevision], even when the identity is the
* same as before.
*/
class StationSelectionState : TableSelectionModel<ChatMember> {
private var current: ChatMember? by mutableStateOf(null)
override val selected: ChatMember?
get() = current
override var selectionRevision: Long by mutableStateOf(0L)
private set
override var scrollRequestRevision: Long by mutableStateOf(0L)
private set
var lastSource: StationSelectionSource by mutableStateOf(StationSelectionSource.PROGRAMMATIC)
private set
val identity: StationIdentity?
get() = StationIdentity.of(current)
override fun select(row: ChatMember?) {
select(row, StationSelectionSource.STATION_TABLE)
}
fun select(row: ChatMember?, source: StationSelectionSource) {
current = row
lastSource = source
selectionRevision++
if (row != null && source.requestsStationTableScroll()) {
scrollRequestRevision++
}
}
override fun reconcile(row: ChatMember?) {
current = row
selectionRevision++
}
override fun requestScroll() {
if (current != null) {
scrollRequestRevision++
}
}
}
private fun StationSelectionSource.requestsStationTableScroll(): Boolean = when (this) {
StationSelectionSource.STATION_TABLE,
StationSelectionSource.STATION_CONTEXT_MENU,
StationSelectionSource.RESET_VIEW,
StationSelectionSource.PROGRAMMATIC -> false
StationSelectionSource.DIRECTED_MESSAGE,
StationSelectionSource.PUBLIC_MESSAGE,
StationSelectionSource.QSO_OF_THE_OTHER,
StationSelectionSource.MAP,
StationSelectionSource.MAP_CONTEXT_MENU,
StationSelectionSource.PRIORITY,
StationSelectionSource.NEW_JOINED,
StationSelectionSource.TIMELINE -> true
}
@@ -49,6 +49,23 @@ data class TimelineCandidate(
val tooltipText: String?,
)
/** 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<TimelineTick> {
if (axisWidth <= 0f) return emptyList()
val previewMinutes = (PREVIEW_MILLIS / 60_000L).toInt()
val interval = listOf(5, 10, 15, previewMinutes).first { minutes ->
axisWidth * minutes / previewMinutes >= minimumLabelSpacing
}
return (0..previewMinutes step interval).map { minutes ->
TimelineTick(minutes, axisWidth * minutes / previewMinutes)
}
}
}
/**
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.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.
*
@@ -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<MapCallsignRawSnapshot>,
@@ -106,6 +113,8 @@ fun ComposeStationMap(
antennaBeamWidthDeg: Double,
maxQrbKm: Double,
onMarkerClicked: ((MapCallsignRawSnapshot) -> Unit)?,
onMarkerContextRequested: ((List<MapCallsignRawSnapshot>, IntOffset) -> Unit)? = null,
workedGrossFields: Set<String> = 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<LayoutCoordinates?>(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<String>,
) {
visibleWorkedGrossFieldCells(viewport, workedGrossFields).forEach { cell ->
val left = viewport.screenX(cell.westLon()).toFloat()
val right = viewport.screenX(cell.eastLon()).toFloat()
val top = viewport.screenY(cell.northLat()).toFloat()
val bottom = viewport.screenY(cell.southLat()).toFloat()
val topLeft = Offset(minOf(left, right), minOf(top, bottom))
val cellSize = Size(kotlin.math.abs(right - left), kotlin.math.abs(bottom - top))
drawRect(
color = WORKED_GRID_FILL,
topLeft = topLeft,
size = cellSize,
)
drawRect(
color = WORKED_GRID_BORDER,
topLeft = topLeft,
size = cellSize,
style = Stroke(width = WORKED_GRID_BORDER_WIDTH_PX),
)
}
}
/** Worked four-character cells visible in the current viewport, independent of markers. */
internal fun visibleWorkedGrossFieldCells(
viewport: MapViewport,
workedGrossFields: Set<String>,
): List<MaidenheadGridUtils.GridCell> {
if (workedGrossFields.isEmpty()) return emptyList()
return MaidenheadGridUtils.buildVisibleCells(
viewport.southLat(),
viewport.westLon(),
viewport.northLat(),
viewport.eastLon(),
MaidenheadGridUtils.GridPrecision.SQUARE_4,
).filter { it.locatorLabel() in workedGrossFields }
}
/* ----------------------------------------------------------- helpers */
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)
@@ -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<PathAnalysisResult?>(null) }
var automaticAnalysisRevision by remember { mutableStateOf(0L) }
// Read through updated state so a caller passing a new lambda is never served a stale one.
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) {
@@ -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 }
@@ -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) }