Files
kst4contest/app-desktop/src/main/kotlin/kst4contest/view/compose/TimelineView.kt
T
Praktimarc a4e10300e7 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
2026-10-24 22:10:36 +02:00

378 lines
13 KiB
Kotlin

package kst4contest.view.compose
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.TooltipArea
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.foundation.onClick
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kst4contest.model.ContestSked
import kst4contest.view.i18n.LocalStrings
import kotlin.math.roundToInt
/** The strip is read like a radar screen, so it stays dark in both designs. */
private val STRIP_BACKGROUND = Color(0xFF2B2B2B)
private val AXIS_COLOR = Color.Gray
/** The glow that marks a target the antenna is centred on. */
private val GLOW_COLOR = Color(0xFF32CD32)
private val MARKER_SIZE = 12.dp
private val LABEL_GAP = 4.dp
private val CHIP_SHAPE = RoundedCornerShape(6.dp)
private val CHIP_BACKGROUND = Color(0xA6000000)
private val LANE_LABEL_WIDTH = 68.dp
private val AXIS_LABEL_PADDING = 6.dp
/**
* The strip above the send field: what is coming in the next half hour.
*
* The one view in this client that is read as a picture rather than as text. Skeds and
* priority candidates are placed by time, coloured by how promising the aircraft
* reflection is, and faded when the antenna points elsewhere — so one glance answers
* both "what is next" and "could I even hear it right now".
*
* Time grows to the right, which means the imminent things are on the LEFT. That is the
* opposite of a progress bar and it is the original's choice, kept because operators
* have learned it.
*
* Markers are drawn rather than built from widgets: this is a diagram, with no rows and
* nothing to select. Only the candidates answer a click.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TimelineView(
state: TimelineState,
onCandidateClicked: (TimelineCandidate) -> Unit,
skedTooltipExtra: (ContestSked) -> String? = { null },
modifier: Modifier = Modifier,
) {
val nowMillis = androidx.compose.runtime.mutableStateOf(System.currentTimeMillis())
androidx.compose.runtime.LaunchedEffect(Unit) {
while (true) {
kotlinx.coroutines.delay(1000)
nowMillis.value = System.currentTimeMillis()
}
}
val density = LocalDensity.current
val measurer = rememberTextMeasurer()
val strings = LocalStrings.current
BoxWithConstraints(
modifier
.height(TimelineGeometry.HEIGHT.dp)
.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(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, axisWidthPx, candidateMargin)
?.plus(labelGutterPx)
?: return@forEach
TimelineMarker(
centerXPx = x,
topDp = TimelineGeometry.laneY(candidate.laneIndex).dp,
stripWidthPx = widthPx,
label = candidate.displayCallSign,
tooltip = candidate.tooltipText,
level = PotentialLevel.forPercent(candidate.opportunityPotentialPercent),
emphasis = state.emphasisFor(candidate.targetAzimuth),
shape = MarkerShape.CANDIDATE,
labelWidthPx = { text -> measurer.measure(text, CHIP_TEXT_STYLE).size.width.toFloat() },
onClick = { onCandidateClicked(candidate) },
)
}
// The skeds, in their own lane below the axis. In JavaFX they shared the space
// above it, which is why their labels sat on top of the candidates'.
val now = nowMillis.value
state.skeds.forEach { sked ->
val x = TimelineGeometry.xFor(sked.skedTimeEpoch - now, axisWidthPx, skedMargin)
?.plus(labelGutterPx)
?: return@forEach
TimelineMarker(
centerXPx = x,
topDp = TimelineGeometry.SKED_Y.dp,
stripWidthPx = widthPx,
label = "SKED: ${sked.targetChatCallsign}",
tooltip = skedTooltip(sked, skedTooltipExtra),
level = PotentialLevel.forPercent(sked.opportunityPotentialPercent),
emphasis = state.emphasisFor(sked.targetAzimuth),
shape = MarkerShape.SKED,
labelWidthPx = { text -> measurer.measure(text, CHIP_TEXT_STYLE).size.width.toFloat() },
onClick = null,
)
}
}
}
/**
* The sked tooltip: who, on what band, in which direction — plus whatever the caller can
* add about the chance itself.
*/
private fun skedTooltip(sked: ContestSked, extraProvider: (ContestSked) -> String?): String {
val base = "${sked.targetChatCallsign} (${sked.band})\nAz: ${sked.targetAzimuth}"
val extra = extraProvider(sked)
return if (extra.isNullOrBlank()) base else "$base\n$extra"
}
private enum class MarkerShape { SKED, CANDIDATE }
private val CHIP_TEXT_STYLE = TextStyle(
color = Color.White,
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
)
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.
*
* The label is never faded, only the symbol is. A ghost marker still has to be
* readable — that is the whole point of showing a chance in a direction the antenna is
* not pointing.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun TimelineMarker(
centerXPx: Float,
topDp: Dp,
stripWidthPx: Float,
label: String,
tooltip: String?,
level: PotentialLevel,
emphasis: AntennaEmphasis,
shape: MarkerShape,
labelWidthPx: (String) -> Float,
onClick: (() -> Unit)?,
) {
val density = LocalDensity.current
val markerSizePx = with(density) { MARKER_SIZE.toPx() }
val gapPx = with(density) { LABEL_GAP.toPx() }
val labelWidth = labelWidthPx(label)
val labelX = TimelineGeometry.labelStartX(
markerX = centerXPx + markerSizePx / 2f,
labelWidth = labelWidth,
stripWidth = stripWidthPx,
gap = gapPx,
)
val symbol: @Composable () -> Unit = {
Canvas(
Modifier
.size(MARKER_SIZE)
.alpha(emphasis.iconAlpha),
) {
scale(emphasis.scale) {
if (emphasis.glowing) drawGlow()
when (shape) {
MarkerShape.SKED -> drawDiamond(markerColor(level))
MarkerShape.CANDIDATE -> drawCandidate(markerColor(level))
}
}
}
}
Box(
Modifier.offset {
IntOffset(
(centerXPx - markerSizePx / 2f).roundToInt(),
with(density) { topDp.toPx() }.roundToInt(),
)
},
) {
val clickable = if (onClick != null) Modifier.onClick { onClick() } else Modifier
if (tooltip.isNullOrBlank()) {
Box(clickable) { symbol() }
} else {
TooltipArea(tooltip = { TimelineTooltip(tooltip) }) {
Box(clickable) { symbol() }
}
}
}
// The label is placed on its own so it can flip sides without moving the symbol.
Box(
Modifier.offset {
IntOffset(
labelX.roundToInt(),
with(density) { topDp.toPx() }.roundToInt(),
)
},
) {
Box(
Modifier
.background(CHIP_BACKGROUND, CHIP_SHAPE)
.padding(horizontal = 4.dp, vertical = 1.dp),
) {
Text(text = label, style = CHIP_TEXT_STYLE, maxLines = 1)
}
}
}
/** Magenta means now or never; deep sky blue means it is barely worth turning for. */
private fun markerColor(level: PotentialLevel): Color = when (level) {
PotentialLevel.HIGHEST -> Color.Magenta
PotentialLevel.HIGH -> Color.Red
PotentialLevel.MEDIUM -> Color.Yellow
PotentialLevel.LOW -> Color(0xFF00BFFF)
}
/** The diamond of a sked: an appointment, not a chance. */
private fun DrawScope.drawDiamond(color: Color) {
val w = size.width
val h = size.height
val path = Path().apply {
moveTo(w / 2f, 0f)
lineTo(w, h / 2f)
lineTo(w / 2f, h)
lineTo(0f, h / 2f)
close()
}
drawPath(path, color)
}
/** A candidate: a downward triangle over a dot, so it reads even at this size. */
private fun DrawScope.drawCandidate(color: Color) {
val w = size.width
val h = size.height
drawCircle(color, radius = w / 3f, center = Offset(w / 2f, h / 2f))
val path = Path().apply {
moveTo(0f, 0f)
lineTo(w, 0f)
lineTo(w / 2f, h * 0.85f)
close()
}
drawPath(path, color)
}
/**
* The glow for a target the antenna is centred on. JavaFX used a drop shadow; here it is
* three fading rings, which costs nothing and reads the same at this size.
*/
private fun DrawScope.drawGlow() {
val center = Offset(size.width / 2f, size.height / 2f)
listOf(1.6f to 0.10f, 1.3f to 0.18f, 1.05f to 0.28f).forEach { (factor, alpha) ->
drawCircle(
color = GLOW_COLOR.copy(alpha = alpha),
radius = size.width / 2f * factor,
center = center,
)
}
}
@Composable
private fun TimelineTooltip(text: String) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
shape = Density.BUTTON_SHAPE,
tonalElevation = 4.dp,
) {
Text(
text = text,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.bodySmall,
)
}
}