Make the monitor window's divider draggable and remembered (#144) (#145)

The cluster and QSO monitor split its two tables at half the height, fixed:
the Compose port had left the JavaFX SplitPane out until a splitter existed.
SplitterPane exists now, so the monitor uses it and reads and writes the same
profile value the SplitPane did (GUIpnl_directedMSGWin_dividerpositionDefault,
default 0.8), so a profile gets its old position back. Each heading now sits
in the pane of its table, and the "DX cluster" heading is translated
(monitor.dxCluster).

Fixes #144.


Claude-Session: https://claude.ai/code/session_01AaorTymXfkXPm8yb4Btva7

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Rsclub2_2andClaude authored and GitHub committed 2026-10-19 15:03:41 +02:00
1 parent d38b385166
commit 4642d658a3
5 files changed
+76 -28

No files matched your search

@@ -426,6 +426,7 @@ column.lastQrg=Letzte QRG
column.airplane=AP [Minuten / Pot%] column.airplane=AP [Minuten / Pot%]
column.score=Wertung column.score=Wertung
monitor.title=Cluster & Fremd-QSOs monitor.title=Cluster & Fremd-QSOs
monitor.dxCluster=DX-Cluster
profiles.createFailed=Das Profil konnte nicht angelegt werden. Die Profilliste konnte nicht geschrieben werden. profiles.createFailed=Das Profil konnte nicht angelegt werden. Die Profilliste konnte nicht geschrieben werden.
profiles.createdWithoutCredentials=Das Profil „{0}" wurde ohne Rufzeichen und Passwort angelegt. Nach dem Wechsel zu diesem Profil beides im Reiter „Station" eintragen. profiles.createdWithoutCredentials=Das Profil „{0}" wurde ohne Rufzeichen und Passwort angelegt. Nach dem Wechsel zu diesem Profil beides im Reiter „Station" eintragen.
profiles.duplicateFailed=Das Profil konnte nicht dupliziert werden. profiles.duplicateFailed=Das Profil konnte nicht dupliziert werden.
@@ -435,6 +435,7 @@ column.lastQrg=Last QRG
column.airplane=AP [minutes / pot%] column.airplane=AP [minutes / pot%]
column.score=Score column.score=Score
monitor.title=Cluster & QSO of the other monitor.title=Cluster & QSO of the other
monitor.dxCluster=DX cluster
profiles.createFailed=The profile could not be created. The profile registry could not be written. profiles.createFailed=The profile could not be created. The profile registry could not be written.
profiles.createdWithoutCredentials=The profile "{0}" was created without callsign and password. Enter them on the Station tab after switching to it. profiles.createdWithoutCredentials=The profile "{0}" was created without callsign and password. Enter them on the Station tab after switching to it.
profiles.duplicateFailed=The profile could not be duplicated. profiles.duplicateFailed=The profile could not be duplicated.
@@ -3706,9 +3706,25 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
clusterTable.replaceRows(chatcontroller.getLst_clusterMemberList().snapshot()); clusterTable.replaceRows(chatcontroller.getLst_clusterMemberList().snapshot());
qsoTable.replaceRows(chatcontroller.toOtherMessages()); qsoTable.replaceRows(chatcontroller.toOtherMessages());
/*
* The same preference the JavaFX SplitPane wrote, so a profile gets back the
* position its operator dragged before the Compose port (#144).
*/
SplitterState monitorSplitter = new SplitterState(
2,
chatcontroller.getChatPreferences().getGUIpnl_directedMSGWin_dividerpositionDefault(),
SplitterState.DEFAULT_MIN_PANE_PX,
positions -> {
chatcontroller.getChatPreferences()
.setGUIpnl_directedMSGWin_dividerpositionDefault(positions);
requestLayoutSave();
return kotlin.Unit.INSTANCE;
});
MonitorWindow.show( MonitorWindow.show(
clusterTable, clusterTable,
qsoTable, qsoTable,
monitorSplitter,
chatcontroller.getChatPreferences().isGUI_darkModeActive(), chatcontroller.getChatPreferences().isGUI_darkModeActive(),
SETTINGS_WINDOW_FONT_SIZE_SP, SETTINGS_WINDOW_FONT_SIZE_SP,
paletteStore, paletteStore,
@@ -4,7 +4,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -17,10 +16,11 @@ import kst4contest.model.ClusterMessage
/** /**
* The cluster and QSO monitor. * The cluster and QSO monitor.
* *
* Two live tables, one above the other. This is the first window in which the table * Two live tables, one above the other, with a divider the operator can drag. This
* carries data that changes while the operator watches, which is why the row identity * is the first window in which the table carries data that changes while the operator
* rules in DataTableState matter here and not in the settings window: in Etappe 2 the * watches, which is why the row identity rules in DataTableState matter here and not in
* visible defects all came from that coupling, not from the table itself. * the settings window: in Etappe 2 the visible defects all came from that coupling, not
* from the table itself.
*/ */
object MonitorWindow { object MonitorWindow {
@@ -44,6 +44,8 @@ object MonitorWindow {
fun show( fun show(
clusterTable: DataTableState<ClusterMessage>, clusterTable: DataTableState<ClusterMessage>,
qsoTable: DataTableState<ChatMessage>, qsoTable: DataTableState<ChatMessage>,
/** The divider between the two tables, remembered in the profile (#144). */
splitter: SplitterState,
darkMode: Boolean, darkMode: Boolean,
baseFontSizeSp: Float, baseFontSizeSp: Float,
/** /**
@@ -70,7 +72,7 @@ object MonitorWindow {
heightDp = heightDp, heightDp = heightDp,
onResized = onResized, onResized = onResized,
) { _ -> ) { _ ->
MonitorContent(clusterTable, qsoTable) MonitorContent(clusterTable, qsoTable, splitter)
} }
} }
} }
@@ -80,29 +82,31 @@ object MonitorWindow {
internal fun MonitorContent( internal fun MonitorContent(
clusterTable: DataTableState<ClusterMessage>, clusterTable: DataTableState<ClusterMessage>,
qsoTable: DataTableState<ChatMessage>, qsoTable: DataTableState<ChatMessage>,
splitter: SplitterState,
) { ) {
val strings = kst4contest.view.i18n.LocalStrings.current
Surface(modifier = Modifier.fillMaxSize()) { Surface(modifier = Modifier.fillMaxSize()) {
Column(modifier = Modifier.fillMaxSize().padding(4.dp)) { /*
Text("DX cluster", style = MaterialTheme.typography.titleSmall) * Each heading sits in the pane of its table, so dragging the divider moves the
* heading with the table it names rather than leaving it stranded above the other.
*/
SplitterPane(
state = splitter,
orientation = SplitterOrientation.VERTICAL,
modifier = Modifier.fillMaxSize().padding(4.dp),
panes = listOf(
{ MonitorPane(strings.monitorDxCluster, clusterTable) },
{ MonitorPane(strings.messagesQsoOfTheOther, qsoTable) },
),
)
}
}
/* @Composable
* Half each, fixed. The JavaFX window used a SplitPane whose divider the private fun <T> MonitorPane(heading: String, table: DataTableState<T>) {
* operator could drag, and that position was stored; reproducing the Column(modifier = Modifier.fillMaxSize()) {
* gesture belongs with the station list of Etappe 5, where a split pane is Text(heading, style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(top = 4.dp))
* unavoidable. Until then an even split is honest rather than a broken DataTableView(table, modifier = Modifier.weight(1f).fillMaxWidth())
* handle.
*/
DataTableView(clusterTable, modifier = Modifier.weight(1f).fillMaxWidth())
HorizontalDivider(thickness = Density.HAIRLINE)
Text(
kst4contest.view.i18n.LocalStrings.current.messagesQsoOfTheOther,
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 4.dp),
)
DataTableView(qsoTable, modifier = Modifier.weight(1f).fillMaxWidth())
}
} }
} }
@@ -1,6 +1,12 @@
package kst4contest.view.compose package kst4contest.view.compose
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.ComposeUiTest import androidx.compose.ui.test.ComposeUiTest
import androidx.compose.ui.test.getBoundsInRoot
import androidx.compose.ui.test.onRoot
import androidx.compose.ui.unit.dp
import androidx.compose.ui.test.ExperimentalTestApi import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
@@ -9,6 +15,7 @@ import kst4contest.model.ChatMember
import kst4contest.model.ChatMessage import kst4contest.model.ChatMessage
import kst4contest.model.ClusterMessage import kst4contest.model.ClusterMessage
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
/** /**
@@ -56,10 +63,13 @@ class MonitorWindowScreenTest {
private fun ComposeUiTest.show( private fun ComposeUiTest.show(
cluster: DataTableState<ClusterMessage>, cluster: DataTableState<ClusterMessage>,
qsos: DataTableState<ChatMessage>, qsos: DataTableState<ChatMessage>,
splitter: SplitterState = SplitterState(paneCount = 2, stored = doubleArrayOf(0.5)) {},
) { ) {
setContent { setContent {
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) { Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
MonitorContent(cluster, qsos) Box(Modifier.size(600.dp, 400.dp)) {
MonitorContent(cluster, qsos, splitter)
}
} }
} }
waitForIdle() waitForIdle()
@@ -75,6 +85,22 @@ class MonitorWindowScreenTest {
onNodeWithText("Call TX").assertExists() onNodeWithText("Call TX").assertExists()
} }
/**
* The two tables used to get half the height each, fixed, and the stored position the
* JavaFX SplitPane had written was read by nobody (#144). The QSO heading is the top of
* the lower pane, so where it sits says where the divider is.
*/
@Test
fun theStoredDividerPositionSplitsTheWindow() = runComposeUiTest {
show(clusterTable(), qsoTable(), SplitterState(paneCount = 2, stored = doubleArrayOf(0.8)) {})
val window = onRoot().getBoundsInRoot()
val qsoHeadingTop = onNodeWithText("QSO of the other").getBoundsInRoot().top
val share = (qsoHeadingTop - window.top) / (window.bottom - window.top)
assertTrue(share in 0.75f..0.85f, "QSO pane starts at $share of the height, expected about 0.8")
}
@Test @Test
fun messagesArrivingWhileTheWindowIsOpenReachBothTables() = runComposeUiTest { fun messagesArrivingWhileTheWindowIsOpenReachBothTables() = runComposeUiTest {
val cluster = clusterTable() val cluster = clusterTable()