mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-10-18 21:34:03 +02:00
Settings take effect with Apply/Close or Save (#134)
* Add a settings draft that holds edits until they are applied The settings window is to stage its edits and apply them with Apply/Close or Save instead of writing each one at once. SettingsDraft holds one pending change per setting, reads through to the stored value, and on apply writes everything first and then runs each effect key once, in the order the settings were first changed. Form.committed registers its typed entry with the draft so a click on Apply cannot lose it. The spec explains the order and the settings that stay immediate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Stage the settings tabs' edits and apply them with Apply/Close or Save Every tab state now reads through the window's draft and stages its edits there instead of writing ChatPreferences: Station (all but the login credentials), Log synch, TRX synch, Airscout, Notification, Shortcuts, Beacon, Message handling and GUI (all but the language). The lists stage their roster, the committed fields stage what their rule accepts. Effects stay attached to their settings. The Win-Test listener and the DX cluster server share one effect for switch and port that brings the running service in line with the applied state, so applying both restarts it once, on the new port. The own-QRG follower runs once for both sources. Apply/Close applies and closes, Save applies and writes, Connect applies before it connects, so the session uses what the tabs show. A field that refuses its entry on the way stops the button with the reason shown. Immediate as before: the login credentials, the language, the colours, the file chooser, the test spot and the profile and worked-database actions. Number fields keep the typed text while the staged value comes back as Compose state; otherwise "1" became "1.0" before the next key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Ask about unapplied settings when the window is closed Closing the settings window with its X, or through the Windows menu, while edits wait in the draft now asks: apply them, discard them, or go back to the window. Without pending edits the window closes as before. Fields and list rows still holding a typed entry are committed first, so an entry typed just before the X counts. The request only sets Compose state; the window decides on its next frame, so nothing waits on the event thread. A close from outside -- a profile switch, the shutdown -- discards the draft: the preferences it belongs to are about to be replaced. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Document that settings take effect with Apply PROJECT_CONTEXT records the draft and what stays immediate, the changelog gets one entry under v1.50, and the configuration manual no longer says that settings take effect while they are edited. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Repair stored settings once before the settings window is built The fallback band of the DX cluster was repaired to 144 inside a getter that runs during composition, and the Win-Test broadcast address was auto-detected in a tab state's constructor. Both wrote ChatPreferences past the draft at a moment nobody could see. repairStoredSettings now does both once, before any tab is built; the tab states only read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Ask before a profile switch or exit, and check fields before applying A profile switch and the exit dropped pending settings edits silently. They now go through SettingsWindow.settleThen: with edits pending, the window is raised and asks Apply / Discard / Back; Apply applies them to the profile still in force, Back cancels the switch or the exit. The question is Compose state and the continuation is posted from the dialog's callback, so the event thread never waits. Without pending edits both go on at once. Fields are checked before anything is applied: a field still showing text that does not parse, or that its rule refuses, refuses the apply and is named in the notice, so the valid edits stay pending and Discard is still possible. This covers number fields and the guarded external-interface fields, which used to let Apply go ahead with the last valid value. Every write and effect of an apply now runs on its own; failures are collected and reported, a failed write stays pending and skips its effect, and Save still writes the file when only an effect failed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Check every drafted setting with a real draft, and rename write-through tests DraftedSettingsTableTest walks every drafted setter of every tab with a real SettingsDraft: unchanged after the edit, changed after apply, unchanged after discard. The tab tests run with a draft that applies at once and cannot tell a drafted setting from one that bypasses the draft. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Document the question on switch and exit, the field checks and the repairs Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Decide the pending-settings question on the event thread, never trap The question is now a SettingsQuestion decided in settle() on the calling event thread instead of in the settings window's next frame, so an exit from the main window does not depend on frames of a minimised settings window; the window is raised and restored before the dialog is needed. While a question is open, further requests are ignored: an X no longer replaces the continuation of an exit or switch with a plain close. Each continuation of settleThen runs at most once. A field holding text it refuses no longer traps the operator: the X, the switch and the exit show the question anyway, with the reason, Apply disabled and Discard and Back available. The apply-failure notice keeps the technical detail out of the sentence, in parentheses after a translated one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c64e9a9014
commit
f17ae0905f
50 files changed
+3079
-615
No files matched your search
@@ -1,5 +1,5 @@
|
||||
# German. Held complete by a test, because this is a shipped language.
|
||||
airscout.applyHint=Serverkennung, Clientkennung und Frequenzmodus wirken sofort. Nach einer Änderung des UDP-Ports neu verbinden.
|
||||
airscout.applyHint=Serverkennung, Clientkennung und Frequenzmodus wirken nach dem Übernehmen. Nach einer Änderung des UDP-Ports neu verbinden.
|
||||
airscout.automaticFrequency=AirScout-Frequenz je Station automatisch wählen
|
||||
airscout.automaticHint=Die automatische Wahl nimmt zuerst die aktuelle QRG der Station, dann Hinweise aus Stationsname und Chat-Kategorie. Nur abschalten, um für jede Station einen festen Protokollwert zu erzwingen.
|
||||
airscout.clientIdentifier=KST4Contest-Clientkennung [KST]:
|
||||
@@ -88,6 +88,13 @@ notification.soundsSection=Töne
|
||||
notification.speakCallsign=Das Rufzeichen bei einer empfangenen Privatnachricht ansagen
|
||||
notification.spotterCallsign=Spotter-Rufzeichen
|
||||
settings.applyAndClose=Übernehmen/Schließen
|
||||
settings.applyFailed=Einige Einstellungen konnten nicht übernommen werden oder haben die laufende Sitzung nicht erreicht.\n\n(Technische Details: {0})
|
||||
settings.pendingApply=Übernehmen
|
||||
settings.pendingBack=Zurück zu den Einstellungen
|
||||
settings.pendingDiscard=Verwerfen
|
||||
settings.pendingQuestion=Einige Einstellungen wurden geändert, aber noch nicht übernommen. Zuerst übernehmen?
|
||||
settings.pendingTitle=Nicht übernommene Änderungen
|
||||
settings.invalidEntry={0}: „{1}" ist keine gültige Eingabe.
|
||||
settings.save=Einstellungen speichern
|
||||
shortcuts.addButton=Kurztaste hinzufügen
|
||||
shortcuts.addSnippet=Neuen Textbaustein hinzufügen
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
#
|
||||
# Nothing that goes to the ON4KST server or to other radio amateurs belongs in this file --
|
||||
# none of it is here, and three tests keep it that way.
|
||||
airscout.applyHint=Server identifier, client identifier and frequency mode are applied immediately. Reconnect after changing the UDP port.
|
||||
airscout.applyHint=Server identifier, client identifier and frequency mode take effect when applied. Reconnect after changing the UDP port.
|
||||
airscout.automaticFrequency=Select AirScout frequency automatically per station
|
||||
airscout.automaticHint=Automatic selection uses the station's current QRG first, then station-name and chat-category evidence. Disable it only to force one protocol value for every station.
|
||||
airscout.clientIdentifier=KST4Contest client identifier [KST]:
|
||||
@@ -97,6 +97,13 @@ notification.soundsSection=Sounds
|
||||
notification.speakCallsign=Speak the callsign on a received private message
|
||||
notification.spotterCallsign=Spotter callsign
|
||||
settings.applyAndClose=Apply/Close prefs
|
||||
settings.applyFailed=Some settings could not be applied, or their change did not reach the running session.\n\n(Technical details: {0})
|
||||
settings.pendingApply=Apply
|
||||
settings.pendingBack=Back to the settings
|
||||
settings.pendingDiscard=Discard
|
||||
settings.pendingQuestion=Some settings have been changed but not applied yet. Apply them first?
|
||||
settings.pendingTitle=Changes not applied
|
||||
settings.invalidEntry={0}: "{1}" is not a valid entry.
|
||||
settings.save=Save settings
|
||||
shortcuts.addButton=Add shortcut
|
||||
shortcuts.addSnippet=Add new snippet
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
package kst4contest.view;
|
||||
import kst4contest.view.compose.SettingsDraft;
|
||||
import kst4contest.view.compose.SettingsHost;
|
||||
import kst4contest.view.compose.SettingsNotices;
|
||||
import kst4contest.view.compose.SettingsTabsKt;
|
||||
@@ -1985,11 +1986,20 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
*/
|
||||
private void requestOperatorProfileSwitch(OperatorProfile targetProfile) {
|
||||
|
||||
if (targetProfile == null || !confirmOperatorProfileSwitch(targetProfile)) {
|
||||
if (targetProfile == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
ApplicationRuntimeLauncher.switchProfile(targetProfile);
|
||||
/*
|
||||
* Settings edits not applied yet are asked about first: applied, they land in the
|
||||
* profile being left, which is the one they were made in; "back" cancels the switch.
|
||||
* Without pending edits this continues at once, as before.
|
||||
*/
|
||||
SettingsWindow.settleThen(() -> {
|
||||
if (confirmOperatorProfileSwitch(targetProfile)) {
|
||||
ApplicationRuntimeLauncher.switchProfile(targetProfile);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2421,6 +2431,15 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
private void closeWindowEvent() {
|
||||
System.out.println("Window close request ...");
|
||||
|
||||
/*
|
||||
* Settings edits not applied yet are asked about before the quit question; "back"
|
||||
* cancels the exit. Without pending edits this continues at once, as before.
|
||||
*/
|
||||
SettingsWindow.settleThen(this::confirmAndExit);
|
||||
}
|
||||
|
||||
/** Asks whether to quit, and quits on yes. */
|
||||
private void confirmAndExit() {
|
||||
/*
|
||||
* ConfirmKind.YES and not the default: the JavaFX dialog used ButtonType.YES here,
|
||||
* and JavaFX ordered YES ahead of CANCEL on Linux and Windows but behind it on
|
||||
@@ -3751,10 +3770,17 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
*/
|
||||
private void openSettingsWindow() {
|
||||
|
||||
/*
|
||||
* One draft per opening: the tabs stage into it, the buttons apply it, and the window
|
||||
* asks about it when closed with edits pending.
|
||||
*/
|
||||
SettingsDraft draft = new SettingsDraft();
|
||||
|
||||
SettingsWindow.show(
|
||||
SettingsTabsKt.buildSettingsTabs(chatcontroller, this, settingsNotices, paletteStore, languageStore,
|
||||
this::settingsConnectionState),
|
||||
this::settingsConnectionState, draft),
|
||||
settingsNotices,
|
||||
draft,
|
||||
chatcontroller.getChatPreferences().isGUI_darkModeActive(),
|
||||
SETTINGS_WINDOW_FONT_SIZE_SP,
|
||||
paletteStore,
|
||||
@@ -3767,7 +3793,7 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
requestLayoutSave();
|
||||
return kotlin.Unit.INSTANCE;
|
||||
},
|
||||
SettingsTabsKt.settingsButtons(chatcontroller, this, settingsNotices));
|
||||
SettingsTabsKt.settingsButtons(chatcontroller, this, settingsNotices, draft));
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -3839,10 +3865,13 @@ public class Kst4ContestApplication implements StatusUpdateListener, SettingsHos
|
||||
chatcontroller.getMessageTXBus().add(sendMe);
|
||||
}
|
||||
|
||||
/** Options/Windows -> open or close the settings window. */
|
||||
/**
|
||||
* Options/Windows -> open or close the settings window. Closing goes the way of the
|
||||
* window's own X, so edits not applied yet are asked about rather than dropped.
|
||||
*/
|
||||
void menuActionToggleSettingsWindow() {
|
||||
if (SettingsWindow.isOpen()) {
|
||||
SettingsWindow.close();
|
||||
SettingsWindow.requestClose();
|
||||
} else {
|
||||
openSettingsWindow();
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ class ComposeWindowHost(private val threadName: String) {
|
||||
* [deiconifiedState] for what to write — so the only thing left here that no headless
|
||||
* test can observe is the single call to `toFront`.
|
||||
*/
|
||||
private fun raise() {
|
||||
fun raise() {
|
||||
val frame = this.frame ?: return
|
||||
|
||||
EventQueue.invokeLater {
|
||||
|
||||
@@ -15,6 +15,8 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
@@ -240,7 +242,7 @@ class EditableListState(
|
||||
* reject it and put the stored value back.
|
||||
*
|
||||
* @param onChanged called after every committed change — add, edit, removal, move.
|
||||
* The lists write through, so this is where the roster is filled.
|
||||
* The settings tabs stage the list here; applying the draft fills the roster.
|
||||
* @param onRefused shown to the operator when a rule refuses an edit; the JavaFX
|
||||
* tables opened an alert here.
|
||||
* @param onAdd replaces the default "insert the template at the top" behaviour.
|
||||
@@ -300,6 +302,23 @@ fun DataTable(
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Inside the settings window a row still being edited is committed before the
|
||||
* draft is applied: a click on "Apply/Close prefs" leaves the focus in the row,
|
||||
* see SettingsDraft.registerTypedEntry. Only an edited row; committing an
|
||||
* untouched one would report a change that was never made. A refusal is reported
|
||||
* by commit() through onRefused, so the registration answers null.
|
||||
*/
|
||||
val settingsDraft = LocalSettingsDraft.current
|
||||
val pendingEdit = rememberUpdatedState { if (draft != entry.text) commit() }
|
||||
DisposableEffect(settingsDraft, entry.key) {
|
||||
val registration = settingsDraft?.registerTypedEntry {
|
||||
pendingEdit.value()
|
||||
null
|
||||
}
|
||||
onDispose { registration?.close() }
|
||||
}
|
||||
|
||||
val isSelected = state.selectedIndex == index
|
||||
|
||||
/*
|
||||
|
||||
@@ -33,10 +33,12 @@ import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -124,12 +126,12 @@ object Form {
|
||||
* looser where two spellings mean one value, as with colours. It must agree
|
||||
* with what [commit] stores: a draft that commit would store as `c` has to count
|
||||
* as unchanged against `c`, or leaving the field after Enter commits it again.
|
||||
* @param current the value stored right now, read when the field commits. The draft
|
||||
* is judged against it, and falls back to it on a refusal (what the JavaFX
|
||||
* fields did with setText). Required, because [stored] alone goes stale when it
|
||||
* comes from a plain getter: nothing recomposes the caller after a commit, so
|
||||
* after Enter it still names the old value, and once the preference is reset
|
||||
* behind the field it names the new one by coincidence only.
|
||||
* @param current the value in force right now, read when the field commits: inside the
|
||||
* settings window the value staged in the draft, otherwise the stored one. The
|
||||
* typed text is judged against it, and falls back to it on a refusal (what the
|
||||
* JavaFX fields did with setText). Required, because [stored] alone goes stale
|
||||
* when it comes from a plain getter: nothing need recompose the caller after a
|
||||
* commit, so after Enter it can still name the old value.
|
||||
*/
|
||||
@Composable
|
||||
fun committed(
|
||||
@@ -166,6 +168,24 @@ object Form {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Inside the settings window the draft can commit this field without a focus change:
|
||||
* a click on "Apply/Close prefs" may leave the focus in the field, and the entry would
|
||||
* otherwise reach the draft only when the closing window removed the field. Through
|
||||
* an updated state, because commitDraft closes over this composition's parameters.
|
||||
* A refusal is reported by commitDraft itself through onRefused, as on focus loss, so
|
||||
* the registration answers null and the caller sees the refusal as the notice.
|
||||
*/
|
||||
val settingsDraft = LocalSettingsDraft.current
|
||||
val latestCommit = rememberUpdatedState(::commitDraft)
|
||||
DisposableEffect(settingsDraft) {
|
||||
val entry = settingsDraft?.registerTypedEntry {
|
||||
latestCommit.value()
|
||||
null
|
||||
}
|
||||
onDispose { entry?.close() }
|
||||
}
|
||||
|
||||
labelled(label) {
|
||||
CompactTextField(
|
||||
value = draft,
|
||||
@@ -304,34 +324,53 @@ object Form {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun int(label: String, value: Int, onChange: (Int) -> Unit) {
|
||||
var draft by remember(value) { mutableStateOf(value.toString()) }
|
||||
|
||||
labelled(label) {
|
||||
CompactTextField(
|
||||
value = draft,
|
||||
onValueChange = { typed ->
|
||||
draft = typed
|
||||
typed.trim().toIntOrNull()?.let(onChange)
|
||||
},
|
||||
isError = draft.trim().toIntOrNull() == null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
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) {
|
||||
var draft by remember(value) { mutableStateOf(value.toString()) }
|
||||
fun decimal(label: String, value: Double, onChange: (Double) -> Unit) =
|
||||
number(label, value, { it.trim().replace(',', '.').toDoubleOrNull() }, onChange)
|
||||
|
||||
/**
|
||||
* The number field behind [int] and [decimal].
|
||||
*
|
||||
* The text follows the stored value only when that value is not what the operator's own
|
||||
* text already says. A settings draft is Compose state, so the value an entry stages comes
|
||||
* straight back as [value]; resetting the text to `value.toString()` then would turn a
|
||||
* typed "1" into "1.0" before the next digit, and "135,5" could never be typed.
|
||||
*/
|
||||
@Composable
|
||||
private fun <N> number(label: String, value: N, parse: (String) -> N?, onChange: (N) -> Unit) {
|
||||
// The last text typed. A plain holder: it only seeds the text below, nothing draws it.
|
||||
val typed = remember { arrayOf(value.toString()) }
|
||||
var draft by remember(value) {
|
||||
mutableStateOf(if (parse(typed[0]) == value) typed[0] else value.toString())
|
||||
}
|
||||
|
||||
/*
|
||||
* Text that does not parse stages nothing, so the draft still holds the last valid
|
||||
* value while the field shows something else in red. Applying then would put a value
|
||||
* into effect the operator no longer sees; the field refuses instead, naming itself.
|
||||
*/
|
||||
val strings = kst4contest.view.i18n.LocalStrings.current
|
||||
val settingsDraft = LocalSettingsDraft.current
|
||||
val refusal = rememberUpdatedState {
|
||||
if (parse(draft) == null) strings.settingsInvalidEntry(label, draft) else null
|
||||
}
|
||||
DisposableEffect(settingsDraft) {
|
||||
val entry = settingsDraft?.registerTypedEntry { refusal.value() }
|
||||
onDispose { entry?.close() }
|
||||
}
|
||||
|
||||
labelled(label) {
|
||||
CompactTextField(
|
||||
value = draft,
|
||||
onValueChange = { typed ->
|
||||
draft = typed
|
||||
typed.trim().replace(',', '.').toDoubleOrNull()?.let(onChange)
|
||||
onValueChange = { entered ->
|
||||
typed[0] = entered
|
||||
draft = entered
|
||||
parse(entered)?.let(onChange)
|
||||
},
|
||||
isError = draft.trim().replace(',', '.').toDoubleOrNull() == null,
|
||||
isError = parse(draft) == null,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.mutableStateMapOf
|
||||
import kotlin.properties.ReadWriteProperty
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
/**
|
||||
* The edits made in the settings window that have not been applied yet.
|
||||
*
|
||||
* One draft exists per opened settings window and is handed to every tab state. A tab
|
||||
* state stages a change here instead of writing ChatPreferences, and reads its values
|
||||
* back through [valueOf], so the window shows what the operator entered while the running
|
||||
* session still uses what is stored. "Apply/Close prefs" and "Save settings" call [apply];
|
||||
* closing the window and choosing to discard calls [discard].
|
||||
*
|
||||
* Why a list of pending writes rather than a copy of ChatPreferences: a setting is more
|
||||
* than its value. Several settings carry an effect on the running session -- the beacon
|
||||
* timer restarts, a UDP listener rebinds, the DX cluster server starts -- and that effect
|
||||
* has to stay attached to the setting it belongs to, so that applying runs exactly what an
|
||||
* immediate edit ran, once per changed setting.
|
||||
*
|
||||
* The order [apply] keeps is part of the contract, see there. The design and the list of
|
||||
* settings that deliberately bypass the draft are in
|
||||
* `docs/superpowers/specs/2026-10-16-settings-apply-drafts-design.md`.
|
||||
*
|
||||
* Not thread-safe, and it need not be: the settings window is drawn on the AWT event
|
||||
* thread, and every caller -- a control, a button, the close request -- runs there.
|
||||
* Open for the tests, which use a draft that applies every change at once to pin the
|
||||
* rules of a tab state without the draft in between.
|
||||
*/
|
||||
open class SettingsDraft {
|
||||
|
||||
private class Change(
|
||||
val value: Any?,
|
||||
val write: () -> Unit,
|
||||
val effectKey: String?,
|
||||
val effect: (() -> Unit)?,
|
||||
)
|
||||
|
||||
/*
|
||||
* Snapshot state, so a composition reading a value through valueOf is redrawn when that
|
||||
* value is staged. The tab states are plain facades; this is what makes them observable.
|
||||
*/
|
||||
private val changes = mutableStateMapOf<String, Change>()
|
||||
|
||||
/*
|
||||
* The order in which settings were first changed. The state map keeps no order, and
|
||||
* apply() must not run effects in hash order.
|
||||
*/
|
||||
private val order = mutableListOf<String>()
|
||||
|
||||
private val typedEntries = mutableListOf<() -> String?>()
|
||||
|
||||
/** Whether anything waits to be applied. Compose state: a composition reading it follows. */
|
||||
val hasPendingChanges: Boolean
|
||||
get() = changes.isNotEmpty()
|
||||
|
||||
/** The staged value of [key], or the stored one read from [live]. */
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun <T> valueOf(key: String, live: () -> T): T {
|
||||
val change = changes[key] ?: return live()
|
||||
return change.value as T
|
||||
}
|
||||
|
||||
/**
|
||||
* Records a change of the setting [key]. A later stage of the same key replaces the value
|
||||
* and keeps the position. Staging the stored value removes the change: a box ticked and
|
||||
* unticked again leaves nothing to apply and nothing to ask about on closing.
|
||||
*
|
||||
* Only valid values may be staged. The fields refuse an invalid entry before it gets
|
||||
* here, as they always did.
|
||||
*
|
||||
* @param live reads the stored value
|
||||
* @param write stores the value; called by [apply]
|
||||
* @param effectKey settings with the same key share one effect, which [apply] runs once.
|
||||
* Defaults to [key].
|
||||
* @param effect what the running session needs after the write, run by [apply] after all
|
||||
* writes
|
||||
*/
|
||||
open fun <T> stage(
|
||||
key: String,
|
||||
value: T,
|
||||
live: () -> T,
|
||||
write: (T) -> Unit,
|
||||
effectKey: String? = null,
|
||||
effect: (() -> Unit)? = null,
|
||||
) {
|
||||
if (value == live()) {
|
||||
changes.remove(key)
|
||||
order.remove(key)
|
||||
return
|
||||
}
|
||||
|
||||
if (!changes.containsKey(key)) {
|
||||
order += key
|
||||
}
|
||||
changes[key] = Change(value, { write(value) }, effectKey ?: key, effect)
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes every change, then runs every effect, then forgets what was written.
|
||||
*
|
||||
* Writes come first, in the order the settings were first changed, so that each effect
|
||||
* sees the finished state: enabling the Win-Test listener and moving its port in one go
|
||||
* would otherwise restart the listener on the old port. Effects follow in the same order,
|
||||
* each effect key once: two restarts of one socket in quick succession can fail on the
|
||||
* port the first one has not released yet.
|
||||
*
|
||||
* Every write and every effect runs on its own: one that throws is recorded and the rest
|
||||
* still run, so a socket that cannot be rebound does not leave the beacon timer or the
|
||||
* next setting unapplied. A change whose write failed stays in the draft, and its effect
|
||||
* does not run -- it would act on a value that was never stored. A failed effect's write
|
||||
* did happen, so that change is gone from the draft like every other.
|
||||
*
|
||||
* Typed entries still held by a field are not committed here; the caller does that first
|
||||
* and decides on a refusal before anything is applied, see [commitTypedEntries].
|
||||
*
|
||||
* @return what could not be done; empty when everything went through
|
||||
*/
|
||||
fun apply(): ApplyOutcome {
|
||||
val staged = order.mapNotNull { key -> changes[key]?.let { key to it } }
|
||||
val failedWrites = mutableListOf<String>()
|
||||
val failedKeys = HashSet<String>()
|
||||
|
||||
staged.forEach { (key, change) ->
|
||||
try {
|
||||
change.write()
|
||||
} catch (failure: Exception) {
|
||||
failedWrites += describe(key, failure)
|
||||
failedKeys += key
|
||||
}
|
||||
}
|
||||
|
||||
val failedEffects = mutableListOf<String>()
|
||||
val ran = HashSet<String?>()
|
||||
staged.forEach { (key, change) ->
|
||||
val effect = change.effect ?: return@forEach
|
||||
if (key in failedKeys || !ran.add(change.effectKey)) return@forEach
|
||||
try {
|
||||
effect()
|
||||
} catch (failure: Exception) {
|
||||
failedEffects += describe(change.effectKey ?: key, failure)
|
||||
}
|
||||
}
|
||||
|
||||
staged.forEach { (key, _) ->
|
||||
if (key !in failedKeys) {
|
||||
changes.remove(key)
|
||||
order.remove(key)
|
||||
}
|
||||
}
|
||||
|
||||
return ApplyOutcome(failedWrites, failedEffects)
|
||||
}
|
||||
|
||||
/** Forgets every change without writing anything. */
|
||||
fun discard() {
|
||||
clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a field that holds typed text the draft has not seen yet: a field that commits
|
||||
* on leaving it (Form.committed), a list row being edited, or a field whose text does not
|
||||
* parse and therefore staged nothing.
|
||||
*
|
||||
* Clicking a button does not reliably take the focus from a text field, so without this an
|
||||
* entry typed into such a field and followed straight by "Apply/Close prefs" would be
|
||||
* committed only when the closing window removed the field -- into a draft nobody applies
|
||||
* any more. And a field showing invalid text in red would let Apply go ahead with the last
|
||||
* valid value, which the operator no longer sees.
|
||||
*
|
||||
* @param commit commits what the field holds and returns null, or returns the reason it
|
||||
* cannot be accepted -- worded for the operator, naming the field
|
||||
* @return closing it unregisters the field
|
||||
*/
|
||||
fun registerTypedEntry(commit: () -> String?): AutoCloseable {
|
||||
typedEntries += commit
|
||||
return AutoCloseable { typedEntries.remove(commit) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits what the registered fields hold, so it is staged, found unchanged or refused.
|
||||
*
|
||||
* @return the refusals, empty when every field was accepted
|
||||
*/
|
||||
fun commitTypedEntries(): List<String> =
|
||||
typedEntries.toList().mapNotNull { it() }
|
||||
|
||||
private fun describe(key: String, failure: Exception): String =
|
||||
key.substringAfterLast('.') + ": " + (failure.message ?: failure.javaClass.simpleName)
|
||||
|
||||
private fun clear() {
|
||||
changes.clear()
|
||||
order.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What [SettingsDraft.apply] could not do, each entry worded as "setting: reason".
|
||||
*
|
||||
* @param failedWrites settings that could not be stored; they stay in the draft
|
||||
* @param failedEffects effects that failed after their settings were stored
|
||||
*/
|
||||
class ApplyOutcome(val failedWrites: List<String>, val failedEffects: List<String>) {
|
||||
|
||||
val failures: List<String>
|
||||
get() = failedWrites + failedEffects
|
||||
|
||||
val isClean: Boolean
|
||||
get() = failures.isEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* A tab-state property whose reads go through [this] draft and whose writes are staged in it.
|
||||
*
|
||||
* The key is the owning class and the property name, so two tabs cannot collide and a
|
||||
* property needs no key spelled out beside it.
|
||||
*
|
||||
* @param read reads the stored value
|
||||
* @param write stores a value; runs on apply
|
||||
* @param effectKey see [SettingsDraft.stage]
|
||||
* @param effect see [SettingsDraft.stage]
|
||||
*/
|
||||
fun <T> SettingsDraft.setting(
|
||||
read: () -> T,
|
||||
effectKey: String? = null,
|
||||
effect: (() -> Unit)? = null,
|
||||
write: (T) -> Unit,
|
||||
): ReadWriteProperty<Any, T> = object : ReadWriteProperty<Any, T> {
|
||||
|
||||
private fun keyOf(owner: Any, property: KProperty<*>) =
|
||||
(owner::class.simpleName ?: "settings") + "." + property.name
|
||||
|
||||
override fun getValue(thisRef: Any, property: KProperty<*>): T =
|
||||
valueOf(keyOf(thisRef, property), read)
|
||||
|
||||
override fun setValue(thisRef: Any, property: KProperty<*>, value: T) {
|
||||
stage(keyOf(thisRef, property), value, read, write, effectKey, effect)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The draft of the settings window being drawn, or null outside it. Form.committed registers
|
||||
* its typed entry here; the tab screen tests draw a tab alone and need none.
|
||||
*/
|
||||
val LocalSettingsDraft = compositionLocalOf<SettingsDraft?> { null }
|
||||
@@ -37,6 +37,7 @@ import kst4contest.view.compose.tabs.TrxSynchTab
|
||||
import kst4contest.view.compose.tabs.TrxSynchTabState
|
||||
import kst4contest.view.compose.tabs.WorkedDatabaseTab
|
||||
import kst4contest.view.compose.tabs.WorkedDatabaseTabState
|
||||
import kst4contest.view.compose.tabs.repairStoredSettings
|
||||
|
||||
/**
|
||||
* The work the settings window needs from the main window.
|
||||
@@ -118,6 +119,8 @@ interface SettingsHost {
|
||||
* @param connectionState the session state as Compose state reads it; the Station tab
|
||||
* locks the login fields from it. The controller's own getter is not observable,
|
||||
* so a tab reading it would stay on whatever the link was when the window opened.
|
||||
* @param draft the edits of this opening of the window; every tab state stages into it,
|
||||
* and the button bar applies it
|
||||
*/
|
||||
fun buildSettingsTabs(
|
||||
controller: ChatController,
|
||||
@@ -126,12 +129,17 @@ fun buildSettingsTabs(
|
||||
paletteStore: PaletteStore,
|
||||
languageStore: kst4contest.view.i18n.LanguageStore,
|
||||
connectionState: () -> On4KstConnectionState,
|
||||
draft: SettingsDraft,
|
||||
): List<SettingsTab> {
|
||||
|
||||
val prefs = controller.chatPreferences
|
||||
|
||||
// Once, before any tab reads: see repairStoredSettings for why not inside the tabs.
|
||||
repairStoredSettings(prefs, host::detectWintestBroadcastAddress)
|
||||
|
||||
val stationState = StationTabState(
|
||||
prefs,
|
||||
draft,
|
||||
applyMainCategory = { category -> controller.setChatCategoryMain(category) },
|
||||
applySecondCategory = { category -> controller.setChatCategorySecondChat(category) },
|
||||
connectionState = connectionState,
|
||||
@@ -139,20 +147,22 @@ fun buildSettingsTabs(
|
||||
|
||||
val notificationState = NotificationTabState(
|
||||
prefs,
|
||||
draft,
|
||||
controller.lstNotify_QSOSniffer_sniffedCallSignList,
|
||||
applyDxClusterServerEnabled = { enabled ->
|
||||
applyDxClusterServer = {
|
||||
/*
|
||||
* Starting a server that nothing can reach yet is pointless, so the JavaFX
|
||||
* checkbox only touched the server while the session was logged in; the
|
||||
* flag alone decides what happens at the next login.
|
||||
* flag alone decides what happens at the next login. Restarted rather than
|
||||
* started when enabled: the same call then binds a changed port, and a server
|
||||
* that was not running is simply started.
|
||||
*/
|
||||
if (controller.isConnectedAndLoggedIn) {
|
||||
if (enabled) controller.startDxClusterServerIfEnabled() else controller.stopDxClusterServer()
|
||||
}
|
||||
},
|
||||
applyDxClusterServerPort = {
|
||||
if (controller.isConnectedAndLoggedIn && prefs.isNotify_dxClusterServerEnabled()) {
|
||||
controller.restartDxClusterServerIfEnabled()
|
||||
if (prefs.isNotify_dxClusterServerEnabled()) {
|
||||
controller.restartDxClusterServerIfEnabled()
|
||||
} else {
|
||||
controller.stopDxClusterServer()
|
||||
}
|
||||
}
|
||||
},
|
||||
broadcastTestSpot = { spot -> deliverTestSpot(controller, spot) },
|
||||
@@ -160,12 +170,14 @@ fun buildSettingsTabs(
|
||||
|
||||
val shortcutsState = ShortcutsTabState(
|
||||
prefs,
|
||||
draft,
|
||||
refreshShortcutButtons = host::refreshShortcutButtonsFromSettings,
|
||||
refreshTextSnippetContextMenus = host::refreshTextSnippetContextMenusFromSettings,
|
||||
)
|
||||
|
||||
val beaconState = BeaconTabState(
|
||||
prefs,
|
||||
draft,
|
||||
validateBeaconTemplate = { template -> controller.validateBeaconTemplate(template) },
|
||||
restartBeaconTimer = { controller.restartBeaconTimer() },
|
||||
mainCategoryName = categoryName(controller.chatCategoryMain, kst4contest.view.i18n.CurrentStrings.get().settingsCategoryMainFallback),
|
||||
@@ -174,9 +186,9 @@ fun buildSettingsTabs(
|
||||
|
||||
val logSynchState = LogSynchTabState(
|
||||
prefs,
|
||||
draft,
|
||||
restartWintestListener = { controller.restartWintestUdpListenerIfEnabled() },
|
||||
stopWintestListener = { controller.stopWintestUdpListener() },
|
||||
detectWintestBroadcastAddress = host::detectWintestBroadcastAddress,
|
||||
)
|
||||
|
||||
val workedDatabaseState = WorkedDatabaseTabState(
|
||||
@@ -213,12 +225,12 @@ fun buildSettingsTabs(
|
||||
return listOf(
|
||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabStation }) { StationTab(stationState) },
|
||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabLogSynch }) { LogSynchTab(logSynchState) },
|
||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabTrxSynch }) { TrxSynchTab(TrxSynchTabState(prefs, host::applyOwnQrgFollower)) },
|
||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabAirscout }) { AirscoutTab(AirscoutTabState(prefs)) },
|
||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabTrxSynch }) { TrxSynchTab(TrxSynchTabState(prefs, draft, host::applyOwnQrgFollower)) },
|
||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabAirscout }) { AirscoutTab(AirscoutTabState(prefs, draft)) },
|
||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabNotification }) { NotificationTab(notificationState, reportRefusal) },
|
||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabShortcuts }) { ShortcutsTab(shortcutsState, reportRefusal) },
|
||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabBeacon }) { BeaconTab(beaconState, reportRefusal) },
|
||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabMessageHandling }) { MessageHandlingTab(MessageHandlingTabState(prefs, host::applyDebugModeToFile)) },
|
||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabMessageHandling }) { MessageHandlingTab(MessageHandlingTabState(prefs, draft, host::applyDebugModeToFile)) },
|
||||
/*
|
||||
* Selecting this tab re-read the database in the JavaFX window; the list is a
|
||||
* snapshot and would otherwise show what was true when the window opened.
|
||||
@@ -227,7 +239,7 @@ fun buildSettingsTabs(
|
||||
{ kst4contest.view.i18n.CurrentStrings.get().tabWorkedDatabase },
|
||||
onSelected = { controller.refreshWorkedStateAndDatabaseListFromDatabase() },
|
||||
) { WorkedDatabaseTab(workedDatabaseState) },
|
||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabGui }) { GuiOptionsTab(GuiOptionsTabState(prefs, languageStore)) },
|
||||
SettingsTab({ kst4contest.view.i18n.CurrentStrings.get().tabGui }) { GuiOptionsTab(GuiOptionsTabState(prefs, draft, languageStore)) },
|
||||
/*
|
||||
* Appended last on purpose so no established tab position shifts, the reason
|
||||
* the JavaFX tab gave for the same placement.
|
||||
@@ -291,11 +303,19 @@ private fun categoryName(category: kst4contest.model.ChatCategory?, fallback: St
|
||||
* Built here because it needs the controller for its state and the host for its
|
||||
* actions, and because it belongs with the tabs it sits under. The `close` action comes
|
||||
* from the window itself: only the window can end its own Compose application.
|
||||
*
|
||||
* Three buttons apply the [draft] before they act. "Apply/Close prefs" applies and closes,
|
||||
* "Save settings" applies and writes the file, and Connect applies before it connects: the
|
||||
* session would otherwise use the stored server or second-chat switch rather than the one
|
||||
* the tab shows. A field that refuses what it holds stops the button before anything is
|
||||
* applied, so the operator sees why instead of a closed window or a saved file; the X still
|
||||
* offers Discard. A write or effect that fails is reported in a problem notice.
|
||||
*/
|
||||
fun settingsButtons(
|
||||
controller: ChatController,
|
||||
host: SettingsHost,
|
||||
notices: SettingsNotices,
|
||||
draft: SettingsDraft,
|
||||
): @Composable (close: () -> Unit) -> Unit {
|
||||
val barState = ConnectionBarState(controller.chatPreferences, controller)
|
||||
|
||||
@@ -311,19 +331,14 @@ fun settingsButtons(
|
||||
ConnectionBar(
|
||||
state = barState,
|
||||
onConnect = {
|
||||
val refusal = host.connectFromSettings()
|
||||
if (refusal != null) notices.problem(refusal)
|
||||
if (applyChecked(draft, notices)) {
|
||||
val refusal = host.connectFromSettings()
|
||||
if (refusal != null) notices.problem(refusal)
|
||||
}
|
||||
revision++
|
||||
},
|
||||
onSave = {
|
||||
val target = host.savePreferencesFromSettings()
|
||||
if (target == null) {
|
||||
notices.problem(kst4contest.view.i18n.CurrentStrings.get().settingsWriteFailed)
|
||||
} else {
|
||||
notices.info(kst4contest.view.i18n.CurrentStrings.get().settingsWrittenTo(target))
|
||||
}
|
||||
},
|
||||
onApplyAndClose = close,
|
||||
onSave = { applyAndSave(draft, notices, host::savePreferencesFromSettings) },
|
||||
onApplyAndClose = { if (applyChecked(draft, notices)) close() },
|
||||
onDisconnectAndCloseChat = host::disconnectAndCloseChatFromSettings,
|
||||
onDisconnectOnly = {
|
||||
host.disconnectOnlyFromSettings()
|
||||
@@ -332,3 +347,74 @@ fun settingsButtons(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits what the fields still hold and tells whether all of it was accepted. On a refusal
|
||||
* the reason is the notice on screen and nothing has been applied yet. The operator corrects
|
||||
* the field, or discards everything through the window's X, whose question offers Discard
|
||||
* even while a field refuses (see SettingsQuestion).
|
||||
*
|
||||
* A problem notice already standing counts as a refusal too. The click on a button can
|
||||
* itself take the focus from a field, which then commits -- and is refused, reporting it --
|
||||
* before the button's action runs. A problem notice from earlier cannot be standing here:
|
||||
* its dialog covers the window, so nothing in it could have been clicked.
|
||||
*/
|
||||
internal fun typedEntriesAccepted(draft: SettingsDraft, notices: SettingsNotices): Boolean {
|
||||
val refusals = draft.commitTypedEntries()
|
||||
if (refusals.isNotEmpty()) {
|
||||
notices.problem(refusals.joinToString("\n"))
|
||||
return false
|
||||
}
|
||||
return notices.current !is SettingsNotice.Problem
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the typed entries, then applies [draft], and tells whether the caller may go on:
|
||||
* false on a refusal (nothing applied) or when a write or an effect failed (the rest
|
||||
* applied, the failures named in a problem notice).
|
||||
*/
|
||||
internal fun applyChecked(draft: SettingsDraft, notices: SettingsNotices): Boolean {
|
||||
if (!typedEntriesAccepted(draft, notices)) return false
|
||||
|
||||
val outcome = draft.apply()
|
||||
if (!outcome.isClean) {
|
||||
notices.problem(applyFailedText(outcome))
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* The work of "Save settings": check the typed entries, apply the draft, write the file, say
|
||||
* where it went. The window stays open.
|
||||
*
|
||||
* A failed effect does not stop the file: its setting was stored, and the file should hold
|
||||
* it. A failed write does, because the file would then miss what the window shows.
|
||||
*
|
||||
* @param save writes the preferences and returns the file, or null when it could not
|
||||
*/
|
||||
internal fun applyAndSave(draft: SettingsDraft, notices: SettingsNotices, save: () -> String?) {
|
||||
if (!typedEntriesAccepted(draft, notices)) return
|
||||
|
||||
val outcome = draft.apply()
|
||||
if (outcome.failedWrites.isNotEmpty()) {
|
||||
notices.problem(applyFailedText(outcome))
|
||||
return
|
||||
}
|
||||
|
||||
val strings = kst4contest.view.i18n.CurrentStrings.get()
|
||||
val target = save()
|
||||
when {
|
||||
target == null -> notices.problem(
|
||||
listOfNotNull(
|
||||
outcome.takeIf { !it.isClean }?.let(::applyFailedText),
|
||||
strings.settingsWriteFailed,
|
||||
).joinToString("\n\n")
|
||||
)
|
||||
!outcome.isClean -> notices.problem(applyFailedText(outcome) + "\n\n" + strings.settingsWrittenTo(target))
|
||||
else -> notices.info(strings.settingsWrittenTo(target))
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyFailedText(outcome: ApplyOutcome): String =
|
||||
kst4contest.view.i18n.CurrentStrings.get().settingsApplyFailed(outcome.failures.joinToString("; "))
|
||||
@@ -11,6 +11,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
@@ -29,12 +30,16 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.unit.dp
|
||||
import java.awt.EventQueue
|
||||
import kst4contest.view.i18n.LocalStrings
|
||||
|
||||
/**
|
||||
* One tab of the settings window: a title and the content to show.
|
||||
@@ -104,6 +109,90 @@ class SettingsNotices {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The question about edits not applied yet, asked when the window closes, the profile is
|
||||
* switched or the client exits.
|
||||
*
|
||||
* The decision is taken in [settle], on the calling event thread, not in the window's next
|
||||
* frame: a minimised window may draw no frames, and an exit from the main window would then
|
||||
* wait for the settings window to be restored. Only the dialog itself needs a frame, and the
|
||||
* window is raised before it is needed. Compose state, so the open window shows the question.
|
||||
*/
|
||||
class SettingsQuestion {
|
||||
|
||||
/** What a shown question goes on with. */
|
||||
class Asked(
|
||||
/** Run after Apply or Discard; null means close the window, which is what the X wants. */
|
||||
val then: (() -> Unit)?,
|
||||
/**
|
||||
* Why Apply cannot be offered: a field holds text it refuses. Discard and Back still
|
||||
* work, so the operator is never trapped behind a field he only wants to get rid of.
|
||||
*/
|
||||
val refusal: String?,
|
||||
)
|
||||
|
||||
/** The question being shown, or null. */
|
||||
var asking: Asked? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
/** Counts the closes asked for with nothing to settle; the window closes on each. */
|
||||
var closeRequests by mutableStateOf(0)
|
||||
private set
|
||||
|
||||
/**
|
||||
* Settles the pending edits of [draft]: runs [nothingPending] at once when nothing waits
|
||||
* and no field refuses what it holds, and shows the question otherwise.
|
||||
*
|
||||
* While a question is shown a further request is ignored. Replacing its continuation would
|
||||
* turn an exit the operator is being asked about into a mere close of the window -- or the
|
||||
* other way round.
|
||||
*
|
||||
* @return true when a question is shown now
|
||||
*/
|
||||
fun settle(
|
||||
draft: SettingsDraft,
|
||||
notices: SettingsNotices,
|
||||
then: (() -> Unit)?,
|
||||
nothingPending: () -> Unit,
|
||||
): Boolean {
|
||||
if (asking != null) return true
|
||||
|
||||
val refusals = draft.commitTypedEntries()
|
||||
// A refusal a field reported on its own focus loss is standing as the notice.
|
||||
val standing = notices.current as? SettingsNotice.Problem
|
||||
val refusal = refusals.takeIf { it.isNotEmpty() }?.joinToString("\n") ?: standing?.message
|
||||
|
||||
if (refusal == null && !draft.hasPendingChanges) {
|
||||
nothingPending()
|
||||
return false
|
||||
}
|
||||
|
||||
// The reason moves into the question, so two dialogs do not stand on top of each other.
|
||||
if (standing != null) notices.dismiss()
|
||||
asking = Asked(then, refusal)
|
||||
return true
|
||||
}
|
||||
|
||||
/** The X of the window: close when nothing waits, ask otherwise. */
|
||||
fun settleClose(draft: SettingsDraft, notices: SettingsNotices) {
|
||||
settle(draft, notices, then = null, nothingPending = { closeRequests++ })
|
||||
}
|
||||
|
||||
/** The question was answered; called before acting on the answer. */
|
||||
fun answered() {
|
||||
asking = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps [action] so it runs at most once, however often it is invoked: a continuation of a
|
||||
* profile switch or an exit must not run twice because a dialog was clicked twice.
|
||||
*/
|
||||
internal fun runOnce(action: Runnable): Runnable {
|
||||
val ran = java.util.concurrent.atomic.AtomicBoolean(false)
|
||||
return Runnable { if (ran.compareAndSet(false, true)) action.run() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings window.
|
||||
*
|
||||
@@ -112,9 +201,11 @@ class SettingsNotices {
|
||||
* most once — a second instance would let two windows write the same preferences
|
||||
* against each other.
|
||||
*
|
||||
* Saving does not collect values. Every control writes into ChatPreferences as it
|
||||
* changes; the button bar only persists, which is what the JavaFX "Save settings"
|
||||
* button did.
|
||||
* The controls stage their edits in a [SettingsDraft]; the button bar applies it. Closing
|
||||
* the window, switching the operator profile or leaving the client while edits are waiting
|
||||
* first asks whether to apply or discard them, or to go back, which cancels the close, the
|
||||
* switch or the exit ([requestClose], [settleThen]). Only [close] -- the teardown that
|
||||
* follows -- and a JVM shutdown hook drop them without asking.
|
||||
*/
|
||||
object SettingsWindow {
|
||||
|
||||
@@ -135,11 +226,67 @@ object SettingsWindow {
|
||||
@JvmStatic
|
||||
fun applyDarkMode(darkMode: Boolean) = host.applyDarkMode(darkMode)
|
||||
|
||||
/** Closes the window and waits for it to be gone; see ComposeWindowHost.close. */
|
||||
/** What the open window was built with, if one is open. */
|
||||
private class OpenWindow(
|
||||
val draft: SettingsDraft,
|
||||
val notices: SettingsNotices,
|
||||
val question: SettingsQuestion,
|
||||
)
|
||||
|
||||
@Volatile
|
||||
private var openWith: OpenWindow? = null
|
||||
|
||||
/**
|
||||
* Closes the window and waits for it to be gone; see ComposeWindowHost.close. Edits not
|
||||
* applied yet are dropped, so this is the teardown after [settleThen] has asked, not the
|
||||
* operator's close; that one goes through [requestClose].
|
||||
*/
|
||||
@JvmStatic
|
||||
fun close() = host.close()
|
||||
|
||||
/**
|
||||
* Closes the window the way its X does: at once when nothing waits to be applied,
|
||||
* otherwise after asking.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun requestClose() {
|
||||
val open = openWith
|
||||
if (open == null || !host.isOpen) return
|
||||
if (open.question.settle(open.draft, open.notices, then = null, nothingPending = { host.close() })) {
|
||||
host.raise()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs [action] once the window's pending edits are settled: at once when the window is
|
||||
* closed or nothing waits, otherwise after the operator chose Apply or Discard in the
|
||||
* window. Back cancels -- [action] never runs. For the profile switch and the exit, so
|
||||
* that neither drops edits the operator has not decided about. While a question is
|
||||
* already shown the request is ignored, see [SettingsQuestion.settle].
|
||||
*
|
||||
* Must be called on the event thread, and never waits there. The decision is taken here
|
||||
* and now; with something to ask the window is raised -- de-iconified if minimised, so it
|
||||
* draws again -- and the question set as Compose state. The action then runs from the
|
||||
* dialog's callback, posted to the event queue, so whatever it opens -- the switch
|
||||
* confirmation, the window teardown -- does not run inside the click handler. It runs at
|
||||
* most once.
|
||||
*/
|
||||
@JvmStatic
|
||||
fun settleThen(action: Runnable) {
|
||||
val once = runOnce(action)
|
||||
val open = openWith
|
||||
if (open == null || !host.isOpen) {
|
||||
once.run()
|
||||
return
|
||||
}
|
||||
if (open.question.settle(open.draft, open.notices, then = { EventQueue.invokeLater(once) }, nothingPending = once::run)) {
|
||||
host.raise()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param draft the edits of this opening of the window; the tabs and the buttons were
|
||||
* built with the same one
|
||||
* @param buttons the bottom button row; it receives the action that closes the
|
||||
* window, because only the window itself can end its Compose application
|
||||
*/
|
||||
@@ -147,6 +294,7 @@ object SettingsWindow {
|
||||
fun show(
|
||||
tabs: List<SettingsTab>,
|
||||
notices: SettingsNotices,
|
||||
draft: SettingsDraft,
|
||||
darkMode: Boolean,
|
||||
baseFontSizeSp: Float,
|
||||
/**
|
||||
@@ -164,6 +312,15 @@ object SettingsWindow {
|
||||
onResized: (Float, Float) -> Unit,
|
||||
buttons: @Composable (close: () -> Unit) -> Unit,
|
||||
) {
|
||||
if (host.isOpen) {
|
||||
// Raises the open window; the new draft belongs to no window and is dropped.
|
||||
host.raise()
|
||||
return
|
||||
}
|
||||
|
||||
val question = SettingsQuestion()
|
||||
openWith = OpenWindow(draft, notices, question)
|
||||
|
||||
host.show(
|
||||
title = { kst4contest.view.i18n.CurrentStrings.get().settingsWindowTitle },
|
||||
darkMode = darkMode,
|
||||
@@ -173,68 +330,101 @@ object SettingsWindow {
|
||||
widthDp = widthDp,
|
||||
heightDp = heightDp,
|
||||
onResized = onResized,
|
||||
// The X is clicked in a window that is showing, so its frames run.
|
||||
onCloseRequest = { question.settleClose(draft, notices) },
|
||||
) { close ->
|
||||
SettingsContent(tabs, notices, buttons, close)
|
||||
SettingsContent(tabs, notices, buttons, close, draft, question)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param draft the edits waiting to be applied. Provided to the tabs, so a field still holding
|
||||
* a typed entry can be committed into it before an apply.
|
||||
* @param question the question about the pending edits, and the closes asked for without one
|
||||
*/
|
||||
@Composable
|
||||
internal fun SettingsContent(
|
||||
tabs: List<SettingsTab>,
|
||||
notices: SettingsNotices,
|
||||
buttons: @Composable (close: () -> Unit) -> Unit,
|
||||
close: () -> Unit,
|
||||
draft: SettingsDraft = SettingsDraft(),
|
||||
question: SettingsQuestion = SettingsQuestion(),
|
||||
) {
|
||||
var selected by remember { mutableStateOf(0) }
|
||||
val closes = question.closeRequests
|
||||
LaunchedEffect(closes) {
|
||||
if (closes > 0) close()
|
||||
}
|
||||
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
SettingsTabStrip(
|
||||
tabs = tabs,
|
||||
selectedIndex = selected,
|
||||
onSelect = { index ->
|
||||
selected = index
|
||||
tabs[index].onSelected?.invoke()
|
||||
},
|
||||
)
|
||||
CompositionLocalProvider(LocalSettingsDraft provides draft) {
|
||||
Surface(modifier = Modifier.fillMaxSize()) {
|
||||
Column(
|
||||
modifier = Modifier.padding(10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
SettingsTabStrip(
|
||||
tabs = tabs,
|
||||
selectedIndex = selected,
|
||||
onSelect = { index ->
|
||||
selected = index
|
||||
tabs[index].onSelected?.invoke()
|
||||
},
|
||||
)
|
||||
|
||||
/*
|
||||
* One scroll region for the whole window, with a scrollbar beside it. The
|
||||
* tabs used to scroll each for themselves, and none of them could show a
|
||||
* bar: a tab does not know the window it sits in, so content simply ended
|
||||
* mid-form with nothing to say that more was below.
|
||||
*/
|
||||
val scroll = rememberScrollState()
|
||||
/*
|
||||
* One scroll region for the whole window, with a scrollbar beside it. The
|
||||
* tabs used to scroll each for themselves, and none of them could show a
|
||||
* bar: a tab does not know the window it sits in, so content simply ended
|
||||
* mid-form with nothing to say that more was below.
|
||||
*/
|
||||
val scroll = rememberScrollState()
|
||||
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scroll)
|
||||
.padding(end = Density.SCROLLBAR_GUTTER),
|
||||
) {
|
||||
tabs.getOrNull(selected)?.content?.invoke()
|
||||
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(scroll)
|
||||
.padding(end = Density.SCROLLBAR_GUTTER),
|
||||
) {
|
||||
tabs.getOrNull(selected)?.content?.invoke()
|
||||
}
|
||||
|
||||
VerticalScrollbar(
|
||||
adapter = rememberScrollbarAdapter(scroll),
|
||||
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
|
||||
style = LocalScrollbarStyle.current.copy(
|
||||
/* Muted on purpose: a bar in the accent colour shouts. */
|
||||
unhoverColor = MaterialTheme.colorScheme.outline,
|
||||
hoverColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
VerticalScrollbar(
|
||||
adapter = rememberScrollbarAdapter(scroll),
|
||||
modifier = Modifier.align(Alignment.CenterEnd).fillMaxHeight(),
|
||||
style = LocalScrollbarStyle.current.copy(
|
||||
/* Muted on purpose: a bar in the accent colour shouts. */
|
||||
unhoverColor = MaterialTheme.colorScheme.outline,
|
||||
hoverColor = MaterialTheme.colorScheme.onSurface,
|
||||
),
|
||||
)
|
||||
buttons(close)
|
||||
}
|
||||
|
||||
buttons(close)
|
||||
}
|
||||
}
|
||||
|
||||
question.asking?.let { asked ->
|
||||
val proceed = asked.then ?: close
|
||||
PendingChangesDialog(
|
||||
refusal = asked.refusal,
|
||||
onApply = {
|
||||
question.answered()
|
||||
// A refusal or a failure stops here, with the reason shown; nothing goes on.
|
||||
if (applyChecked(draft, notices)) proceed()
|
||||
},
|
||||
onDiscard = {
|
||||
question.answered()
|
||||
draft.discard()
|
||||
proceed()
|
||||
},
|
||||
onBack = { question.answered() },
|
||||
)
|
||||
}
|
||||
|
||||
notices.current?.let { notice ->
|
||||
AlertDialog(
|
||||
onDismissRequest = notices::dismiss,
|
||||
@@ -252,6 +442,46 @@ internal fun SettingsContent(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks what becomes of the edits not applied yet when the operator closes the window,
|
||||
* switches the profile or exits: apply them, discard them, or go back to the window.
|
||||
* Dismissing it goes back -- nothing is lost by a stray click beside it.
|
||||
*
|
||||
* @param refusal set when a field holds text it refuses: shown in the dialog, and Apply is
|
||||
* disabled. Discard and Back stay available, so a field the operator only wants to get
|
||||
* rid of never blocks the close, the switch or the exit.
|
||||
*/
|
||||
@Composable
|
||||
internal fun PendingChangesDialog(
|
||||
refusal: String?,
|
||||
onApply: () -> Unit,
|
||||
onDiscard: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val strings = LocalStrings.current
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onBack,
|
||||
title = { Text(strings.settingsPendingTitle) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(strings.settingsPendingQuestion)
|
||||
// Why Apply is not offered; Discard and Back still are.
|
||||
refusal?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onApply, enabled = refusal == null) { Text(strings.settingsPendingApply) }
|
||||
},
|
||||
dismissButton = {
|
||||
Row {
|
||||
TextButton(onClick = onDiscard) { Text(strings.settingsPendingDiscard) }
|
||||
TextButton(onClick = onBack) { Text(strings.settingsPendingBack) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The row of tab buttons.
|
||||
*
|
||||
|
||||
@@ -16,8 +16,8 @@ import kst4contest.view.compose.Form
|
||||
/**
|
||||
* The Airscout tab: the UDP link to AirScout and how the queried frequency is chosen.
|
||||
*
|
||||
* Labels, defaults and the closing note are the JavaFX ones, including that the server
|
||||
* identifier, the client identifier and the frequency mode take effect at once while a
|
||||
* Labels and defaults are the JavaFX ones. The closing note says that the server
|
||||
* identifier, the client identifier and the frequency mode take effect when applied while a
|
||||
* changed UDP port needs a reconnect.
|
||||
*
|
||||
* The identifier, port and band fields keep what the operator typed and mark it as an
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import kst4contest.model.ChatPreferences
|
||||
import kst4contest.view.compose.SettingsDraft
|
||||
import kst4contest.view.compose.setting
|
||||
|
||||
/**
|
||||
* The Airscout tab as a typed facade over ChatPreferences.
|
||||
*
|
||||
* Every property writes through immediately and there is deliberately no apply(), the
|
||||
* way the JavaFX controls behaved.
|
||||
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
|
||||
* applies them.
|
||||
*
|
||||
* Three properties are guarded before the write, and the guard is not decoration. The
|
||||
* Three properties are guarded before they are staged, and the guard is not decoration. The
|
||||
* ChatPreferences setters do not keep the previous value on bad input, they substitute
|
||||
* the factory default: an empty identifier becomes "AS"/"KST", a port outside 1..65535
|
||||
* becomes 9872, a band value that is not a positive number becomes "1440000". The
|
||||
@@ -19,27 +21,48 @@ import kst4contest.model.ChatPreferences
|
||||
* The accepted ranges are the JavaFX ones, unchanged: AirScout ports and the band
|
||||
* value are protocol values and neither the defaults nor the bounds are touched.
|
||||
*/
|
||||
class AirscoutTabState(private val prefs: ChatPreferences) {
|
||||
class AirscoutTabState(private val prefs: ChatPreferences, private val draft: SettingsDraft) {
|
||||
|
||||
var airScout_asUDPListenerEnabled: Boolean
|
||||
get() = prefs.isAirScout_asUDPListenerEnabled()
|
||||
set(value) { prefs.setAirScout_asUDPListenerEnabled(value) }
|
||||
var airScout_asUDPListenerEnabled: Boolean by draft.setting(
|
||||
{ prefs.isAirScout_asUDPListenerEnabled() },
|
||||
) { prefs.setAirScout_asUDPListenerEnabled(it) }
|
||||
|
||||
/*
|
||||
* The drafted values behind the guarded properties below. Only a valid, normalised value
|
||||
* is staged, so neither the draft nor apply can hand the setters what they would replace
|
||||
* with the factory default.
|
||||
*/
|
||||
private var serverName: String by draft.setting(
|
||||
{ prefs.getAirScout_asServerNameString() },
|
||||
) { prefs.setAirScout_asServerNameString(it) }
|
||||
|
||||
private var clientName: String by draft.setting(
|
||||
{ prefs.getAirScout_asClientNameString() },
|
||||
) { prefs.setAirScout_asClientNameString(it) }
|
||||
|
||||
private var communicationPort: Int by draft.setting(
|
||||
{ prefs.getAirScout_asCommunicationPort() },
|
||||
) { prefs.setAirScout_asCommunicationPort(it) }
|
||||
|
||||
private var bandString: String by draft.setting(
|
||||
{ prefs.getAirScout_asBandString() },
|
||||
) { prefs.setAirScout_asBandString(it) }
|
||||
|
||||
/** Logical identifier of the target AirScout server; invalid input is ignored. */
|
||||
var airScout_asServerNameString: String
|
||||
get() = prefs.getAirScout_asServerNameString()
|
||||
get() = serverName
|
||||
set(value) {
|
||||
if (isValidIdentifier(value)) {
|
||||
prefs.setAirScout_asServerNameString(value.trim())
|
||||
serverName = value.trim()
|
||||
}
|
||||
}
|
||||
|
||||
/** Identifier of this KST4Contest instance; invalid input is ignored. */
|
||||
var airScout_asClientNameString: String
|
||||
get() = prefs.getAirScout_asClientNameString()
|
||||
get() = clientName
|
||||
set(value) {
|
||||
if (isValidIdentifier(value)) {
|
||||
prefs.setAirScout_asClientNameString(value.trim())
|
||||
clientName = value.trim()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,10 +72,10 @@ class AirscoutTabState(private val prefs: ChatPreferences) {
|
||||
* that changes here.
|
||||
*/
|
||||
var airScout_asCommunicationPort: Int
|
||||
get() = prefs.getAirScout_asCommunicationPort()
|
||||
get() = communicationPort
|
||||
set(value) {
|
||||
if (isValidPort(value)) {
|
||||
prefs.setAirScout_asCommunicationPort(value)
|
||||
communicationPort = value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,9 +83,9 @@ class AirscoutTabState(private val prefs: ChatPreferences) {
|
||||
* Pick the AirScout frequency per station instead of forcing one value. Uses the
|
||||
* station's current QRG first, then station-name and chat-category evidence.
|
||||
*/
|
||||
var airScout_autoBandSelectionEnabled: Boolean
|
||||
get() = prefs.isAirScout_autoBandSelectionEnabled()
|
||||
set(value) { prefs.setAirScout_autoBandSelectionEnabled(value) }
|
||||
var airScout_autoBandSelectionEnabled: Boolean by draft.setting(
|
||||
{ prefs.isAirScout_autoBandSelectionEnabled() },
|
||||
) { prefs.setAirScout_autoBandSelectionEnabled(it) }
|
||||
|
||||
/**
|
||||
* The forced AirScout band value, a protocol number such as 1440000 for 144 MHz.
|
||||
@@ -70,9 +93,9 @@ class AirscoutTabState(private val prefs: ChatPreferences) {
|
||||
* value produced in the JavaFX field.
|
||||
*/
|
||||
var airScout_asBandString: String
|
||||
get() = prefs.getAirScout_asBandString()
|
||||
get() = bandString
|
||||
set(value) {
|
||||
parseBandValue(value)?.let { prefs.setAirScout_asBandString(it) }
|
||||
parseBandValue(value)?.let { bandString = it }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,6 +3,8 @@ package kst4contest.view.compose.tabs
|
||||
import kst4contest.controller.ChatController
|
||||
import kst4contest.model.ChatPreferences
|
||||
import kst4contest.view.i18n.CurrentStrings
|
||||
import kst4contest.view.compose.SettingsDraft
|
||||
import kst4contest.view.compose.setting
|
||||
|
||||
/**
|
||||
* State of the beacon tab: the two CQ templates and the one interval they share.
|
||||
@@ -10,34 +12,57 @@ import kst4contest.view.i18n.CurrentStrings
|
||||
* The two commit methods are the ports of `applyBeaconTextSetting` and
|
||||
* `applySharedBeaconInterval`. Both returned nothing and opened an alert; here they
|
||||
* return the refusal text instead, so the tab decides how to show it and a test can
|
||||
* read it.
|
||||
* read it. An accepted value is staged in the window's [SettingsDraft] and takes effect
|
||||
* when the operator applies it; a refused one never gets there.
|
||||
*
|
||||
* @param validateBeaconTemplate throws IllegalArgumentException with the reason,
|
||||
* the contract of `ChatController.validateBeaconTemplate`.
|
||||
* @param restartBeaconTimer called only after the interval actually changed; the
|
||||
* JavaFX code restarted the shared timer there and nowhere else.
|
||||
* @param restartBeaconTimer called when a changed interval is applied; the JavaFX code
|
||||
* restarted the shared timer there and nowhere else.
|
||||
*/
|
||||
class BeaconTabState(
|
||||
private val prefs: ChatPreferences,
|
||||
private val draft: SettingsDraft,
|
||||
private val validateBeaconTemplate: (String) -> Unit,
|
||||
private val restartBeaconTimer: () -> Unit,
|
||||
val mainCategoryName: String,
|
||||
val secondCategoryName: String,
|
||||
) {
|
||||
|
||||
var bcn_beaconsEnabledMainCat: Boolean
|
||||
get() = prefs.isBcn_beaconsEnabledMainCat()
|
||||
set(value) { prefs.setBcn_beaconsEnabledMainCat(value) }
|
||||
var bcn_beaconsEnabledMainCat: Boolean by draft.setting(
|
||||
{ prefs.isBcn_beaconsEnabledMainCat() },
|
||||
) { prefs.setBcn_beaconsEnabledMainCat(it) }
|
||||
|
||||
var bcn_beaconsEnabledSecondCat: Boolean
|
||||
get() = prefs.isBcn_beaconsEnabledSecondCat()
|
||||
set(value) { prefs.setBcn_beaconsEnabledSecondCat(value) }
|
||||
var bcn_beaconsEnabledSecondCat: Boolean by draft.setting(
|
||||
{ prefs.isBcn_beaconsEnabledSecondCat() },
|
||||
) { prefs.setBcn_beaconsEnabledSecondCat(it) }
|
||||
|
||||
private var textMainCat: String by draft.setting(
|
||||
{ prefs.getBcn_beaconTextMainCat() },
|
||||
) { prefs.setBcn_beaconTextMainCat(it) }
|
||||
|
||||
private var textSecondCat: String by draft.setting(
|
||||
{ prefs.getBcn_beaconTextSecondCat() },
|
||||
) { prefs.setBcn_beaconTextSecondCat(it) }
|
||||
|
||||
/*
|
||||
* Both stored intervals written together, as applySharedBeaconInterval did. The
|
||||
* second-category value is legacy XML which nothing reads any more, but it is written
|
||||
* to keep the two in step.
|
||||
*/
|
||||
private var interval: Int by draft.setting(
|
||||
{ prefs.getBcn_beaconIntervalInMinutesMainCat() },
|
||||
effect = { restartBeaconTimer() },
|
||||
) { minutes ->
|
||||
prefs.setBcn_beaconIntervalInMinutesMainCat(minutes)
|
||||
prefs.setBcn_beaconIntervalInMinutesSecondCat(minutes)
|
||||
}
|
||||
|
||||
val beaconTextMainCat: String
|
||||
get() = prefs.getBcn_beaconTextMainCat()
|
||||
get() = textMainCat
|
||||
|
||||
val beaconTextSecondCat: String
|
||||
get() = prefs.getBcn_beaconTextSecondCat()
|
||||
get() = textSecondCat
|
||||
|
||||
/**
|
||||
* The interval both categories share, never below the minimum.
|
||||
@@ -50,16 +75,16 @@ class BeaconTabState(
|
||||
val beaconIntervalMinutes: Int
|
||||
get() = maxOf(
|
||||
ChatController.MIN_BEACON_INTERVAL_MINUTES,
|
||||
prefs.getBcn_beaconIntervalInMinutesMainCat(),
|
||||
interval,
|
||||
)
|
||||
|
||||
/** @return null when stored, otherwise the reason it was refused. */
|
||||
/** @return null when staged, otherwise the reason it was refused. */
|
||||
fun commitBeaconTextMainCat(template: String): String? =
|
||||
commitBeaconText(template) { prefs.setBcn_beaconTextMainCat(it) }
|
||||
commitBeaconText(template) { textMainCat = it }
|
||||
|
||||
/** @return null when stored, otherwise the reason it was refused. */
|
||||
/** @return null when staged, otherwise the reason it was refused. */
|
||||
fun commitBeaconTextSecondCat(template: String): String? =
|
||||
commitBeaconText(template) { prefs.setBcn_beaconTextSecondCat(it) }
|
||||
commitBeaconText(template) { textSecondCat = it }
|
||||
|
||||
private fun commitBeaconText(template: String, store: (String) -> Unit): String? {
|
||||
try {
|
||||
@@ -78,9 +103,9 @@ class BeaconTabState(
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the shared interval to both categories and restarts the timer.
|
||||
* Stages the shared interval for both categories; applying it restarts the timer.
|
||||
*
|
||||
* @return null when applied, otherwise the reason it was refused.
|
||||
* @return null when staged, otherwise the reason it was refused.
|
||||
*/
|
||||
fun commitBeaconInterval(entered: String): String? {
|
||||
val minutes = entered.trim().toIntOrNull()
|
||||
@@ -89,16 +114,7 @@ class BeaconTabState(
|
||||
return CurrentStrings.get().beaconIntervalTooShort(ChatController.MIN_BEACON_INTERVAL_MINUTES.toString())
|
||||
}
|
||||
|
||||
prefs.setBcn_beaconIntervalInMinutesMainCat(minutes)
|
||||
|
||||
/*
|
||||
* The second-category value is legacy XML which nothing reads any more, but
|
||||
* it is written to keep the two in step, exactly as applySharedBeaconInterval
|
||||
* did.
|
||||
*/
|
||||
prefs.setBcn_beaconIntervalInMinutesSecondCat(minutes)
|
||||
|
||||
restartBeaconTimer()
|
||||
interval = minutes
|
||||
return null
|
||||
}
|
||||
}
|
||||
+19
-11
@@ -12,13 +12,14 @@ import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
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.focus.onFocusChanged
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.AwtWindow
|
||||
import java.awt.FileDialog
|
||||
@@ -36,8 +37,7 @@ import java.io.File
|
||||
* back on bad input. Writing through on every keystroke without that guard would be
|
||||
* worse than the old behaviour, because the ChatPreferences setters for the AirScout
|
||||
* identifiers, the AirScout port and the AirScout band value substitute the factory
|
||||
* default on bad input instead of keeping the previous value. It also reports focus
|
||||
* loss, which is where a port change may rebind a socket.
|
||||
* default on bad input instead of keeping the previous value.
|
||||
* - [FilePathRow] is the read-only path plus "Choose..." button of the Log synch tab.
|
||||
*/
|
||||
@Composable
|
||||
@@ -47,7 +47,6 @@ internal fun GuardedTextField(
|
||||
enabled: Boolean = true,
|
||||
accepts: (String) -> Boolean = { true },
|
||||
onAccepted: (String) -> Unit,
|
||||
onCommit: () -> Unit = {},
|
||||
) {
|
||||
/*
|
||||
* Keyed on the stored value: the draft is only reset when the stored value really
|
||||
@@ -55,7 +54,21 @@ internal fun GuardedTextField(
|
||||
* value such as a trailing space that is trimmed away, leaves the typed text alone.
|
||||
*/
|
||||
var draft by remember(value) { mutableStateOf(value) }
|
||||
var hadFocus by remember { mutableStateOf(false) }
|
||||
|
||||
/*
|
||||
* A refused entry stages nothing, so the draft keeps the last accepted value while the
|
||||
* field shows the refused text in red. Inside the settings window the field refuses the
|
||||
* apply instead of letting that hidden value go into effect.
|
||||
*/
|
||||
val strings = kst4contest.view.i18n.LocalStrings.current
|
||||
val settingsDraft = kst4contest.view.compose.LocalSettingsDraft.current
|
||||
val refusal = rememberUpdatedState {
|
||||
if (accepts(draft)) null else strings.settingsInvalidEntry(label, draft)
|
||||
}
|
||||
DisposableEffect(settingsDraft) {
|
||||
val entry = settingsDraft?.registerTypedEntry { refusal.value() }
|
||||
onDispose { entry?.close() }
|
||||
}
|
||||
|
||||
LabelledRow(label) {
|
||||
CompactTextField(
|
||||
@@ -68,12 +81,7 @@ internal fun GuardedTextField(
|
||||
},
|
||||
enabled = enabled,
|
||||
isError = !accepts(draft),
|
||||
modifier = Modifier.fillMaxWidth().onFocusChanged { focusState ->
|
||||
if (hadFocus && !focusState.isFocused) {
|
||||
onCommit()
|
||||
}
|
||||
hadFocus = focusState.isFocused
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,34 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import kst4contest.model.ChatPreferences
|
||||
import kst4contest.view.compose.SettingsDraft
|
||||
import kst4contest.view.compose.setting
|
||||
import kst4contest.view.i18n.LanguageStore
|
||||
|
||||
/**
|
||||
* The GUI tab as a typed facade over ChatPreferences.
|
||||
*
|
||||
* Writes through immediately and has no apply(), like every settings tab: the
|
||||
* JavaFX controls wrote as they changed and "Save settings" only persisted.
|
||||
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
|
||||
* applies them. The language is the exception: it switches at once, because the switch is
|
||||
* itself what the operator wants to see.
|
||||
*/
|
||||
class GuiOptionsTabState(
|
||||
private val prefs: ChatPreferences,
|
||||
private val draft: SettingsDraft,
|
||||
private val languageStore: LanguageStore,
|
||||
) {
|
||||
|
||||
var gUI_darkModeActiveByDefault: Boolean
|
||||
get() = prefs.isGUI_darkModeActiveByDefault()
|
||||
set(value) { prefs.setGUI_darkModeActiveByDefault(value) }
|
||||
var gUI_darkModeActiveByDefault: Boolean by draft.setting({ prefs.isGUI_darkModeActiveByDefault() }) { prefs.setGUI_darkModeActiveByDefault(it) }
|
||||
|
||||
var guiOptions_defaultFilterNothing: Boolean
|
||||
get() = prefs.isGuiOptions_defaultFilterNothing()
|
||||
set(value) { prefs.setGuiOptions_defaultFilterNothing(value) }
|
||||
var guiOptions_defaultFilterNothing: Boolean by draft.setting({ prefs.isGuiOptions_defaultFilterNothing() }) { prefs.setGuiOptions_defaultFilterNothing(it) }
|
||||
|
||||
var guiOptions_defaultFilterPmToMe: Boolean
|
||||
get() = prefs.isGuiOptions_defaultFilterPmToMe()
|
||||
set(value) { prefs.setGuiOptions_defaultFilterPmToMe(value) }
|
||||
var guiOptions_defaultFilterPmToMe: Boolean by draft.setting({ prefs.isGuiOptions_defaultFilterPmToMe() }) { prefs.setGuiOptions_defaultFilterPmToMe(it) }
|
||||
|
||||
var guiOptions_defaultFilterPmToOther: Boolean
|
||||
get() = prefs.isGuiOptions_defaultFilterPmToOther()
|
||||
set(value) { prefs.setGuiOptions_defaultFilterPmToOther(value) }
|
||||
var guiOptions_defaultFilterPmToOther: Boolean by draft.setting({ prefs.isGuiOptions_defaultFilterPmToOther() }) { prefs.setGuiOptions_defaultFilterPmToOther(it) }
|
||||
|
||||
var guiOptions_defaultFilterPublicMsgs: Boolean
|
||||
get() = prefs.isGuiOptions_defaultFilterPublicMsgs()
|
||||
set(value) { prefs.setGuiOptions_defaultFilterPublicMsgs(value) }
|
||||
var guiOptions_defaultFilterPublicMsgs: Boolean by draft.setting({ prefs.isGuiOptions_defaultFilterPublicMsgs() }) { prefs.setGuiOptions_defaultFilterPublicMsgs(it) }
|
||||
|
||||
var guiOptions_showFreshCallHintInBandColumns: Boolean
|
||||
get() = prefs.isGuiOptions_showFreshCallHintInBandColumns()
|
||||
set(value) { prefs.setGuiOptions_showFreshCallHintInBandColumns(value) }
|
||||
var guiOptions_showFreshCallHintInBandColumns: Boolean by draft.setting({ prefs.isGuiOptions_showFreshCallHintInBandColumns() }) { prefs.setGuiOptions_showFreshCallHintInBandColumns(it) }
|
||||
|
||||
/**
|
||||
* The interface language, as the picker offers it: a language code, or the empty string
|
||||
@@ -50,8 +42,6 @@ class GuiOptionsTabState(
|
||||
get() = languageStore.stored
|
||||
set(value) { languageStore.use(value) }
|
||||
|
||||
var guiOptions_showGrossFieldWorkedHintInBandColumns: Boolean
|
||||
get() = prefs.isGuiOptions_showGrossFieldWorkedHintInBandColumns()
|
||||
set(value) { prefs.setGuiOptions_showGrossFieldWorkedHintInBandColumns(value) }
|
||||
var guiOptions_showGrossFieldWorkedHintInBandColumns: Boolean by draft.setting({ prefs.isGuiOptions_showGrossFieldWorkedHintInBandColumns() }) { prefs.setGuiOptions_showGrossFieldWorkedHintInBandColumns(it) }
|
||||
|
||||
}
|
||||
@@ -17,8 +17,8 @@ import kst4contest.view.compose.Form
|
||||
* The Log synch tab: how worked callsigns reach KST4Contest — by polling a log file, by
|
||||
* UDP from N1MM+/QARTEST/UCXLog/DXLog, and by the Win-Test network.
|
||||
*
|
||||
* No confirmation callback: LogSynchTabState writes through to ChatPreferences, the way
|
||||
* the JavaFX controls did. "Save settings" persists, it does not collect.
|
||||
* The controls stage their edits in the window's draft (see LogSynchTabState); the file
|
||||
* chooser writes the chosen path at once.
|
||||
*
|
||||
* Labels, units and default hints are the JavaFX ones. The Win-Test port field is
|
||||
* disabled while its listener is off, as it was there.
|
||||
@@ -75,9 +75,8 @@ fun LogSynchTab(state: LogSynchTabState) {
|
||||
value = state.logsynch_wintestNetworkPort.toString(),
|
||||
enabled = state.logsynch_wintestNetworkListenerEnabled,
|
||||
accepts = { it.trim().toIntOrNull() != null },
|
||||
// The socket is rebound when the port is applied, not per digit.
|
||||
onAccepted = { state.logsynch_wintestNetworkPort = it.trim().toInt(); revision++ },
|
||||
// Rebinding the socket belongs to the finished entry, not to every digit.
|
||||
onCommit = { state.commitWintestNetworkPort() },
|
||||
)
|
||||
|
||||
GuardedTextField(
|
||||
|
||||
@@ -1,23 +1,24 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import kst4contest.model.ChatPreferences
|
||||
import kst4contest.view.compose.SettingsDraft
|
||||
import kst4contest.view.compose.setting
|
||||
|
||||
/**
|
||||
* The Log synch tab as a typed facade over ChatPreferences.
|
||||
*
|
||||
* Every property writes through immediately, and there is deliberately no apply().
|
||||
* That mirrors the JavaFX window it replaces: its controls each wrote into
|
||||
* ChatPreferences the moment they changed, and its two buttons only persisted
|
||||
* ("Save settings" calls writePreferencesToXmlFile) or closed the window.
|
||||
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
|
||||
* applies them. The log file path is the exception: it changes through a file chooser,
|
||||
* an action, and is written when chosen.
|
||||
*
|
||||
* Two things this tab controlled are not settings at all but effects on the running
|
||||
* session, and no coverage test can see them because they are not ChatPreferences
|
||||
* setters. They are taken as callbacks rather than from a ChatController so the rules
|
||||
* stay testable without opening a database:
|
||||
* One thing this tab controls is not a setting at all but an effect on the running session,
|
||||
* and no coverage test can see it because it is not a ChatPreferences setter: the Win-Test
|
||||
* UDP listener is started, stopped or rebound when its switch or its port is applied -- once
|
||||
* per apply, see [applyWintestListener]. It is taken as callbacks rather than from a
|
||||
* ChatController so the rules stay testable without opening a database.
|
||||
*
|
||||
* - the Win-Test UDP listener is started and stopped with its setting;
|
||||
* - the Win-Test broadcast address is auto-detected while it is still the catch-all
|
||||
* default, which the JavaFX window did while building the settings pane.
|
||||
* The broadcast address the JavaFX window auto-detected while building the pane is detected
|
||||
* by repairStoredSettings now, before the window is built, not by this state.
|
||||
*
|
||||
* Ports, transports and framing are untouched: the accepted values, the defaults and
|
||||
* the moment a socket is rebound are the ones the JavaFX window had.
|
||||
@@ -25,23 +26,17 @@ import kst4contest.model.ChatPreferences
|
||||
* @param restartWintestListener rebinds the Win-Test UDP listener; maps to
|
||||
* ChatController.restartWintestUdpListenerIfEnabled()
|
||||
* @param stopWintestListener maps to ChatController.stopWintestUdpListener()
|
||||
* @param detectWintestBroadcastAddress the detected subnet broadcast address, or null
|
||||
* when none could be determined; maps to detectPreferredWintestBroadcastAddress()
|
||||
*/
|
||||
class LogSynchTabState(
|
||||
private val prefs: ChatPreferences,
|
||||
private val draft: SettingsDraft,
|
||||
private val restartWintestListener: () -> Unit,
|
||||
private val stopWintestListener: () -> Unit,
|
||||
private val detectWintestBroadcastAddress: () -> String?,
|
||||
) {
|
||||
|
||||
init {
|
||||
autoDetectBroadcastAddressWhileStillDefault()
|
||||
}
|
||||
|
||||
var logsynch_fileBasedWkdCallInterpreterEnabled: Boolean
|
||||
get() = prefs.isLogsynch_fileBasedWkdCallInterpreterEnabled()
|
||||
set(value) { prefs.setLogsynch_fileBasedWkdCallInterpreterEnabled(value) }
|
||||
var logsynch_fileBasedWkdCallInterpreterEnabled: Boolean by draft.setting(
|
||||
{ prefs.isLogsynch_fileBasedWkdCallInterpreterEnabled() },
|
||||
) { prefs.setLogsynch_fileBasedWkdCallInterpreterEnabled(it) }
|
||||
|
||||
/**
|
||||
* The log file polled for worked callsigns. The JavaFX tab showed it read-only and
|
||||
@@ -53,75 +48,73 @@ class LogSynchTabState(
|
||||
get() = prefs.getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly()
|
||||
set(value) { prefs.setLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly(value) }
|
||||
|
||||
var logsynch_ucxUDPWkdCallListenerEnabled: Boolean
|
||||
get() = prefs.isLogsynch_ucxUDPWkdCallListenerEnabled()
|
||||
set(value) { prefs.setLogsynch_ucxUDPWkdCallListenerEnabled(value) }
|
||||
var logsynch_ucxUDPWkdCallListenerEnabled: Boolean by draft.setting(
|
||||
{ prefs.isLogsynch_ucxUDPWkdCallListenerEnabled() },
|
||||
) { prefs.setLogsynch_ucxUDPWkdCallListenerEnabled(it) }
|
||||
|
||||
/**
|
||||
* Shared UDP port for QSO and TRX messages, default 12060. The JavaFX field
|
||||
* accepted any number here and rebound nothing — that listener comes up at start —
|
||||
* so neither a range nor a restart is added.
|
||||
*/
|
||||
var logsynch_ucxUDPWkdCallListenerPort: Int
|
||||
get() = prefs.getLogsynch_ucxUDPWkdCallListenerPort()
|
||||
set(value) { prefs.setLogsynch_ucxUDPWkdCallListenerPort(value) }
|
||||
var logsynch_ucxUDPWkdCallListenerPort: Int by draft.setting(
|
||||
{ prefs.getLogsynch_ucxUDPWkdCallListenerPort() },
|
||||
) { prefs.setLogsynch_ucxUDPWkdCallListenerPort(it) }
|
||||
|
||||
var logsynch_wintestNetworkListenerEnabled: Boolean
|
||||
get() = prefs.isLogsynch_wintestNetworkListenerEnabled()
|
||||
set(value) {
|
||||
prefs.setLogsynch_wintestNetworkListenerEnabled(value)
|
||||
if (value) restartWintestListener() else stopWintestListener()
|
||||
}
|
||||
var logsynch_wintestNetworkListenerEnabled: Boolean by draft.setting(
|
||||
{ prefs.isLogsynch_wintestNetworkListenerEnabled() },
|
||||
effectKey = WINTEST_LISTENER,
|
||||
effect = ::applyWintestListener,
|
||||
) { prefs.setLogsynch_wintestNetworkListenerEnabled(it) }
|
||||
|
||||
/**
|
||||
* UDP port for the Win-Test listener, default 9871. The setting is written at once
|
||||
* like every other, but the socket is only rebound by [commitWintestNetworkPort] —
|
||||
* the JavaFX field did that on focus loss, and rebinding per keystroke would bind
|
||||
* and unbind a UDP port for every digit on the way to the intended one.
|
||||
* UDP port for the Win-Test listener, default 9871. The socket is rebound when the port
|
||||
* is applied, not per keystroke: rebinding per digit would bind and unbind a UDP port
|
||||
* for every number on the way to the intended one.
|
||||
*/
|
||||
var logsynch_wintestNetworkPort: Int
|
||||
get() = prefs.getLogsynch_wintestNetworkPort()
|
||||
set(value) { prefs.setLogsynch_wintestNetworkPort(value) }
|
||||
var logsynch_wintestNetworkPort: Int by draft.setting(
|
||||
{ prefs.getLogsynch_wintestNetworkPort() },
|
||||
effectKey = WINTEST_LISTENER,
|
||||
effect = ::applyWintestListener,
|
||||
) { prefs.setLogsynch_wintestNetworkPort(it) }
|
||||
|
||||
/** Applies the entered port to the running listener, if there is one. */
|
||||
fun commitWintestNetworkPort() {
|
||||
if (prefs.isLogsynch_wintestNetworkListenerEnabled()) {
|
||||
restartWintestListener()
|
||||
}
|
||||
}
|
||||
/*
|
||||
* The two trimmed values are staged trimmed, so the draft holds what will be stored and a
|
||||
* trailing space does not count as a change.
|
||||
*/
|
||||
private var stationNameOfKst: String by draft.setting(
|
||||
{ prefs.getLogsynch_wintestNetworkStationNameOfKST() },
|
||||
) { prefs.setLogsynch_wintestNetworkStationNameOfKST(it) }
|
||||
|
||||
private var broadcastAddress: String by draft.setting(
|
||||
{ prefs.getLogsynch_wintestNetworkBroadcastAddress() },
|
||||
) { prefs.setLogsynch_wintestNetworkBroadcastAddress(it) }
|
||||
|
||||
/** The name KST4Contest uses as source of Win-Test SKED packets. */
|
||||
var logsynch_wintestNetworkStationNameOfKST: String
|
||||
get() = prefs.getLogsynch_wintestNetworkStationNameOfKST()
|
||||
set(value) { prefs.setLogsynch_wintestNetworkStationNameOfKST(value.trim()) }
|
||||
get() = stationNameOfKst
|
||||
set(value) { stationNameOfKst = value.trim() }
|
||||
|
||||
/** UDP broadcast address used for sending to Win-Test. */
|
||||
var logsynch_wintestNetworkBroadcastAddress: String
|
||||
get() = prefs.getLogsynch_wintestNetworkBroadcastAddress()
|
||||
set(value) { prefs.setLogsynch_wintestNetworkBroadcastAddress(value.trim()) }
|
||||
get() = broadcastAddress
|
||||
set(value) { broadcastAddress = value.trim() }
|
||||
|
||||
/**
|
||||
* Replaces the catch-all default 255.255.255.255 with the detected subnet
|
||||
* broadcast address. An address the operator or a previous detection chose is
|
||||
* left alone, and a failed detection keeps the default: guessing an address here
|
||||
* would send SKED packets somewhere nobody is listening.
|
||||
* Brings the Win-Test listener in line with the applied switch and port: rebound while
|
||||
* enabled, stopped otherwise. The switch and the port share it, so applying both runs it
|
||||
* once and binds the new port; a port changed while the listener is off meets a listener
|
||||
* that is not running, and stopping that does nothing.
|
||||
*/
|
||||
private fun autoDetectBroadcastAddressWhileStillDefault() {
|
||||
if (prefs.getLogsynch_wintestNetworkBroadcastAddress() != DEFAULT_BROADCAST_ADDRESS) {
|
||||
return
|
||||
}
|
||||
val detected = try {
|
||||
detectWintestBroadcastAddress()
|
||||
} catch (probeFailure: RuntimeException) {
|
||||
null
|
||||
}
|
||||
if (detected != null && detected.isNotBlank()) {
|
||||
prefs.setLogsynch_wintestNetworkBroadcastAddress(detected)
|
||||
}
|
||||
private fun applyWintestListener() {
|
||||
if (prefs.isLogsynch_wintestNetworkListenerEnabled()) restartWintestListener() else stopWintestListener()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** The stored value that still means "not configured for this network". */
|
||||
const val DEFAULT_BROADCAST_ADDRESS: String = "255.255.255.255"
|
||||
|
||||
/** The effect key the Win-Test switch and port share. */
|
||||
private const val WINTEST_LISTENER = "wintestListener"
|
||||
}
|
||||
}
|
||||
+25
-21
@@ -1,6 +1,8 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import kst4contest.model.ChatPreferences
|
||||
import kst4contest.view.compose.SettingsDraft
|
||||
import kst4contest.view.compose.setting
|
||||
|
||||
/**
|
||||
* State of the message-handling tab.
|
||||
@@ -10,42 +12,44 @@ import kst4contest.model.ChatPreferences
|
||||
* (Kst4ContestApplication:12004 and :12039). Exposing one property per pair keeps
|
||||
* that: a tab that wrote only the main category would leave the second chat on its
|
||||
* previous value, which no part of the window ever displayed.
|
||||
*
|
||||
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
|
||||
* applies them.
|
||||
*/
|
||||
class MessageHandlingTabState(
|
||||
private val prefs: ChatPreferences,
|
||||
private val draft: SettingsDraft,
|
||||
private val applyDebugModeToFile: (Boolean) -> Unit = {},
|
||||
) {
|
||||
|
||||
/** Drives both category flags, as the single JavaFX checkbox did. */
|
||||
var autoAnswerEnabled: Boolean
|
||||
get() = prefs.isMessageHandling_autoAnswerEnabled()
|
||||
set(value) {
|
||||
prefs.setMessageHandling_autoAnswerEnabled(value)
|
||||
prefs.setMessageHandling_autoAnswerEnabledSecondCat(value)
|
||||
}
|
||||
var autoAnswerEnabled: Boolean by draft.setting(
|
||||
{ prefs.isMessageHandling_autoAnswerEnabled() },
|
||||
) { enabled ->
|
||||
prefs.setMessageHandling_autoAnswerEnabled(enabled)
|
||||
prefs.setMessageHandling_autoAnswerEnabledSecondCat(enabled)
|
||||
}
|
||||
|
||||
/** Drives both category texts, as the single JavaFX text field did. */
|
||||
var autoAnswerText: String
|
||||
get() = prefs.getMessageHandling_autoAnswerTextMainCat()
|
||||
set(value) {
|
||||
prefs.setMessageHandling_autoAnswerTextMainCat(value)
|
||||
prefs.setMessageHandling_autoAnswerTextSecondCat(value)
|
||||
}
|
||||
var autoAnswerText: String by draft.setting(
|
||||
{ prefs.getMessageHandling_autoAnswerTextMainCat() },
|
||||
) { text ->
|
||||
prefs.setMessageHandling_autoAnswerTextMainCat(text)
|
||||
prefs.setMessageHandling_autoAnswerTextSecondCat(text)
|
||||
}
|
||||
|
||||
/** Independent of the automatic answer; its own checkbox in the JavaFX window. */
|
||||
var autoAnswerToQRGRequestEnabled: Boolean
|
||||
get() = prefs.isMessageHandling_autoAnswerToQRGRequestEnabled()
|
||||
set(value) { prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(value) }
|
||||
var autoAnswerToQRGRequestEnabled: Boolean by draft.setting(
|
||||
{ prefs.isMessageHandling_autoAnswerToQRGRequestEnabled() },
|
||||
) { prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(it) }
|
||||
|
||||
/**
|
||||
* Records the whole session to file. Beyond the preference it opens or closes the
|
||||
* error-log handler and the history recorder, which only the application can sequence;
|
||||
* [applyDebugModeToFile] carries that out in the order the JavaFX checkbox kept.
|
||||
*/
|
||||
var debugModeToFileEnabled: Boolean
|
||||
get() = prefs.isMessageHandling_debugModeToFileEnabled()
|
||||
set(value) {
|
||||
prefs.setMessageHandling_debugModeToFileEnabled(value)
|
||||
applyDebugModeToFile(value)
|
||||
}
|
||||
var debugModeToFileEnabled: Boolean by draft.setting(
|
||||
{ prefs.isMessageHandling_debugModeToFileEnabled() },
|
||||
effect = { applyDebugModeToFile(prefs.isMessageHandling_debugModeToFileEnabled()) },
|
||||
) { prefs.setMessageHandling_debugModeToFileEnabled(it) }
|
||||
}
|
||||
@@ -10,121 +10,129 @@ import kst4contest.view.compose.CommitOutcome
|
||||
import kst4contest.view.compose.EditableListState
|
||||
import kst4contest.view.compose.EntryRule
|
||||
import kst4contest.view.i18n.CurrentStrings
|
||||
import kst4contest.view.compose.SettingsDraft
|
||||
import kst4contest.view.compose.setting
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* State of the notification tab: sounds, band-upgrade hints, the local DX Cluster
|
||||
* server and the QSO monitoring list.
|
||||
*
|
||||
* Three of these reach past the preferences into the running session, which the
|
||||
* settings coverage test cannot see, so they are constructor callbacks rather than
|
||||
* plain properties: starting and stopping the server, restarting it after a port
|
||||
* change, and sending the test spot.
|
||||
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
|
||||
* applies them; the monitoring list too. The test spot is an action and is sent at once.
|
||||
*
|
||||
* Two of these reach past the preferences into the running session, which the settings
|
||||
* coverage test cannot see, so they are constructor callbacks rather than plain
|
||||
* properties: bringing the server in line with its switch and port, and sending the test
|
||||
* spot.
|
||||
*
|
||||
* @param monitoredRoster `ChatController.getLstNotify_QSOSniffer_sniffedCallSignList`;
|
||||
* it lives on the controller, not in the preferences, which is why it is
|
||||
* passed separately.
|
||||
* @param applyDxClusterServerEnabled starts or stops the server, the work the
|
||||
* JavaFX checkbox did beyond writing the flag.
|
||||
* @param applyDxClusterServerPort restarts the server; called only when the port
|
||||
* actually changed, as the JavaFX field did.
|
||||
* @param applyDxClusterServer brings the running server in line with the applied switch
|
||||
* and port: restarted while enabled, stopped otherwise. The switch and the port
|
||||
* share it, so one apply restarts the server once, on the new port.
|
||||
* @param broadcastTestSpot delivers the spot and returns null on success or the
|
||||
* reason it could not be delivered.
|
||||
*/
|
||||
class NotificationTabState(
|
||||
private val prefs: ChatPreferences,
|
||||
private val draft: SettingsDraft,
|
||||
private val monitoredRoster: SimpleRoster<String>,
|
||||
private val applyDxClusterServerEnabled: (Boolean) -> Unit,
|
||||
private val applyDxClusterServerPort: (Int) -> Unit,
|
||||
private val applyDxClusterServer: () -> Unit,
|
||||
private val broadcastTestSpot: (ChatMember) -> String?,
|
||||
) {
|
||||
|
||||
var notify_playSimpleSounds: Boolean
|
||||
get() = prefs.isNotify_playSimpleSounds()
|
||||
set(value) { prefs.setNotify_playSimpleSounds(value) }
|
||||
var notify_playSimpleSounds: Boolean by draft.setting(
|
||||
{ prefs.isNotify_playSimpleSounds() },
|
||||
) { prefs.setNotify_playSimpleSounds(it) }
|
||||
|
||||
var notify_playCWCallsignsOnRxedPMs: Boolean
|
||||
get() = prefs.isNotify_playCWCallsignsOnRxedPMs()
|
||||
set(value) { prefs.setNotify_playCWCallsignsOnRxedPMs(value) }
|
||||
var notify_playCWCallsignsOnRxedPMs: Boolean by draft.setting(
|
||||
{ prefs.isNotify_playCWCallsignsOnRxedPMs() },
|
||||
) { prefs.setNotify_playCWCallsignsOnRxedPMs(it) }
|
||||
|
||||
var notify_playVoiceCallsignsOnRxedPMs: Boolean
|
||||
get() = prefs.isNotify_playVoiceCallsignsOnRxedPMs()
|
||||
set(value) { prefs.setNotify_playVoiceCallsignsOnRxedPMs(value) }
|
||||
var notify_playVoiceCallsignsOnRxedPMs: Boolean by draft.setting(
|
||||
{ prefs.isNotify_playVoiceCallsignsOnRxedPMs() },
|
||||
) { prefs.setNotify_playVoiceCallsignsOnRxedPMs(it) }
|
||||
|
||||
var notify_bandUpgradeHintOnLogEnabled: Boolean
|
||||
get() = prefs.isNotify_bandUpgradeHintOnLogEnabled()
|
||||
set(value) { prefs.setNotify_bandUpgradeHintOnLogEnabled(value) }
|
||||
var notify_bandUpgradeHintOnLogEnabled: Boolean by draft.setting(
|
||||
{ prefs.isNotify_bandUpgradeHintOnLogEnabled() },
|
||||
) { prefs.setNotify_bandUpgradeHintOnLogEnabled(it) }
|
||||
|
||||
var notify_bandUpgradePriorityBoostEnabled: Boolean
|
||||
get() = prefs.isNotify_bandUpgradePriorityBoostEnabled()
|
||||
set(value) { prefs.setNotify_bandUpgradePriorityBoostEnabled(value) }
|
||||
var notify_bandUpgradePriorityBoostEnabled: Boolean by draft.setting(
|
||||
{ prefs.isNotify_bandUpgradePriorityBoostEnabled() },
|
||||
) { prefs.setNotify_bandUpgradePriorityBoostEnabled(it) }
|
||||
|
||||
private var dxClusterServerEnabled: Boolean by draft.setting(
|
||||
{ prefs.isNotify_dxClusterServerEnabled() },
|
||||
effectKey = DX_CLUSTER_SERVER,
|
||||
effect = { applyDxClusterServer() },
|
||||
) { prefs.setNotify_dxClusterServerEnabled(it) }
|
||||
|
||||
private var dxClusterServerPort: Int by draft.setting(
|
||||
{ prefs.getNotify_dxclusterServerPort() },
|
||||
effectKey = DX_CLUSTER_SERVER,
|
||||
effect = { applyDxClusterServer() },
|
||||
) { prefs.setNotify_dxclusterServerPort(it) }
|
||||
|
||||
private var spotterCallSign: String by draft.setting(
|
||||
{ prefs.getNotify_DXCSrv_SpottersCallSign().get() },
|
||||
) { prefs.setNotify_DXCSrv_SpottersCallSign(it) }
|
||||
|
||||
val notify_dxClusterServerEnabled: Boolean
|
||||
get() = prefs.isNotify_dxClusterServerEnabled()
|
||||
get() = dxClusterServerEnabled
|
||||
|
||||
val notify_dxclusterServerPort: Int
|
||||
get() = prefs.getNotify_dxclusterServerPort()
|
||||
get() = dxClusterServerPort
|
||||
|
||||
val notify_DXCSrv_SpottersCallSign: String
|
||||
get() = prefs.getNotify_DXCSrv_SpottersCallSign().get()
|
||||
get() = spotterCallSign
|
||||
|
||||
/**
|
||||
* The band a spot falls back to when its frequency carries no band information.
|
||||
*
|
||||
* Stored as a prefix string, so an unreadable value is possible; reading it
|
||||
* repairs the stored value to 144, which is what the JavaFX window did when it
|
||||
* built the combo box (Kst4ContestApplication:11349).
|
||||
/*
|
||||
* Stored as a prefix string, so an unreadable value is possible. It is shown as 144 here
|
||||
* and written back as 144 by repairStoredSettings before the window is built -- never
|
||||
* from this read, which runs during composition.
|
||||
*/
|
||||
private var fallbackBand: Band by draft.setting(
|
||||
{ Band.fromPrefix(prefs.getNotify_optionalFrequencyPrefix().get()) ?: Band.B_144 },
|
||||
) { prefs.setNotify_optionalFrequencyPrefix(it.prefix) }
|
||||
|
||||
/** The band a spot falls back to when its frequency carries no band information. */
|
||||
var notify_optionalFrequencyPrefix: Band
|
||||
get() {
|
||||
val stored = Band.fromPrefix(prefs.getNotify_optionalFrequencyPrefix().get())
|
||||
|
||||
if (stored == null) {
|
||||
prefs.setNotify_optionalFrequencyPrefix(Band.B_144.prefix)
|
||||
return Band.B_144
|
||||
}
|
||||
|
||||
return stored
|
||||
}
|
||||
set(value) { prefs.setNotify_optionalFrequencyPrefix(value.prefix) }
|
||||
get() = fallbackBand
|
||||
set(value) { fallbackBand = value }
|
||||
|
||||
val monitoredCallSigns = EditableListState(
|
||||
monitoredRoster.snapshot(),
|
||||
rule = EntryRule { candidate, _, others -> judgeMonitoredCallSign(candidate, others) },
|
||||
)
|
||||
|
||||
/** Writes the flag and lets the running session start or stop the server. */
|
||||
/** Stages the server switch; applying it starts or stops the server. */
|
||||
fun enableDxClusterServer(enabled: Boolean) {
|
||||
prefs.setNotify_dxClusterServerEnabled(enabled)
|
||||
applyDxClusterServerEnabled(enabled)
|
||||
dxClusterServerEnabled = enabled
|
||||
}
|
||||
|
||||
/**
|
||||
* @return null when applied, otherwise the reason the port was refused.
|
||||
* @return null when staged, otherwise the reason the port was refused.
|
||||
*/
|
||||
fun commitDxClusterServerPort(entered: String): String? {
|
||||
val previousPort = prefs.getNotify_dxclusterServerPort()
|
||||
val port = entered.trim().toIntOrNull()
|
||||
|
||||
if (port == null || port < 1 || port > 65535) {
|
||||
return CurrentStrings.get().notificationInvalidPort(entered.trim())
|
||||
}
|
||||
|
||||
prefs.setNotify_dxclusterServerPort(port)
|
||||
|
||||
/*
|
||||
* Restarting on an unchanged port would drop a connected logger for nothing;
|
||||
* the JavaFX field guarded the restart the same way.
|
||||
* The stored port staged again is no change, so it restarts nothing: restarting on
|
||||
* an unchanged port would drop a connected logger for nothing, and the JavaFX field
|
||||
* guarded the restart the same way.
|
||||
*/
|
||||
if (port != previousPort) {
|
||||
applyDxClusterServerPort(port)
|
||||
}
|
||||
|
||||
dxClusterServerPort = port
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* @return null when stored, otherwise the reason the callsign was refused.
|
||||
* @return null when staged, otherwise the reason the callsign was refused.
|
||||
*/
|
||||
fun commitSpotterCallSign(entered: String): String? {
|
||||
val spotterCallSign = entered.trim().uppercase(Locale.ROOT)
|
||||
@@ -133,13 +141,12 @@ class NotificationTabState(
|
||||
return CurrentStrings.get().notificationInvalidSpotter(spotterCallSign)
|
||||
}
|
||||
|
||||
prefs.setNotify_DXCSrv_SpottersCallSign(spotterCallSign)
|
||||
this.spotterCallSign = spotterCallSign
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends a callsign to the monitoring list and fills the roster at once, as the
|
||||
* JavaFX dialog did.
|
||||
* Appends a callsign to the monitoring list and stages the list.
|
||||
*
|
||||
* @return null when added, otherwise the reason it was refused.
|
||||
*/
|
||||
@@ -163,9 +170,14 @@ class NotificationTabState(
|
||||
return null
|
||||
}
|
||||
|
||||
/** Fills the monitoring roster from the list. */
|
||||
/** Stages the list; applying it fills the monitoring roster. */
|
||||
fun commitMonitoredCallSigns() {
|
||||
monitoredCallSigns.commitTo(monitoredRoster)
|
||||
draft.stage(
|
||||
MONITORED_CALL_SIGNS,
|
||||
monitoredCallSigns.texts.toList(),
|
||||
live = { monitoredRoster.snapshot() },
|
||||
write = { monitoredCallSigns.commitTo(monitoredRoster) },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -211,4 +223,11 @@ class NotificationTabState(
|
||||
|
||||
return CommitOutcome.Stored(baseCall)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** The effect key the server switch and port share. */
|
||||
const val DX_CLUSTER_SERVER = "dxClusterServer"
|
||||
|
||||
const val MONITORED_CALL_SIGNS = "NotificationTabState.monitoredCallSigns"
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,15 @@ package kst4contest.view.compose.tabs
|
||||
|
||||
import kst4contest.model.ChatPreferences
|
||||
import kst4contest.view.compose.EditableListState
|
||||
import kst4contest.view.compose.SettingsDraft
|
||||
|
||||
/**
|
||||
* State of the shortcuts-and-snippets tab: the two single-column lists from
|
||||
* `grdPnlShorts`.
|
||||
*
|
||||
* Both lists write through to their roster on every committed change. The JavaFX
|
||||
* tables did the same — their Add buttons called `mutate` on the roster directly —
|
||||
* and the shortcut buttons above the message field are rebuilt from the roster, so
|
||||
* a list that only wrote on Save would leave those buttons stale.
|
||||
* Every committed change of a list is staged in the window's [SettingsDraft]. Applying
|
||||
* it fills the roster and rebuilds what is built from it -- the shortcut buttons above the
|
||||
* message field, the snippet menus -- once per apply, however many edits went before.
|
||||
*
|
||||
* @param refreshShortcutButtons rebuilds the button row above the message field,
|
||||
* as `refreshShortcutButtons` did after every edit commit and move.
|
||||
@@ -19,6 +19,7 @@ import kst4contest.view.compose.EditableListState
|
||||
*/
|
||||
class ShortcutsTabState(
|
||||
private val prefs: ChatPreferences,
|
||||
private val draft: SettingsDraft,
|
||||
private val refreshShortcutButtons: () -> Unit,
|
||||
private val refreshTextSnippetContextMenus: () -> Unit,
|
||||
) {
|
||||
@@ -27,16 +28,26 @@ class ShortcutsTabState(
|
||||
|
||||
val snippets = EditableListState(prefs.getLst_txtSnipList().snapshot())
|
||||
|
||||
/** Fills the shortcut roster and rebuilds the buttons built from it. */
|
||||
/** Stages the shortcut list; applying it fills the roster and rebuilds the buttons. */
|
||||
fun commitShortcuts() {
|
||||
shortcuts.commitTo(prefs.getLst_txtShortCutBtnList())
|
||||
refreshShortcutButtons()
|
||||
draft.stage(
|
||||
SHORTCUTS,
|
||||
shortcuts.texts.toList(),
|
||||
live = { prefs.getLst_txtShortCutBtnList().snapshot() },
|
||||
write = { shortcuts.commitTo(prefs.getLst_txtShortCutBtnList()) },
|
||||
effect = refreshShortcutButtons,
|
||||
)
|
||||
}
|
||||
|
||||
/** Fills the snippet roster and rebuilds the context menus built from it. */
|
||||
/** Stages the snippet list; applying it fills the roster and rebuilds the menus. */
|
||||
fun commitSnippets() {
|
||||
snippets.commitTo(prefs.getLst_txtSnipList())
|
||||
refreshTextSnippetContextMenus()
|
||||
draft.stage(
|
||||
SNIPPETS,
|
||||
snippets.texts.toList(),
|
||||
live = { prefs.getLst_txtSnipList().snapshot() },
|
||||
write = { snippets.commitTo(prefs.getLst_txtSnipList()) },
|
||||
effect = refreshTextSnippetContextMenus,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
@@ -47,5 +58,8 @@ class ShortcutsTabState(
|
||||
*/
|
||||
const val NEW_ENTRY_TEMPLATE =
|
||||
"CHANGE THIS TEXT VIA DOUBLECLICK or remove by deleting all text. Then hit enter key"
|
||||
|
||||
private const val SHORTCUTS = "ShortcutsTabState.shortcuts"
|
||||
private const val SNIPPETS = "ShortcutsTabState.snippets"
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,8 @@ import kst4contest.view.compose.Form
|
||||
/**
|
||||
* The Station tab: own station, server, active bands and path-analysis defaults.
|
||||
*
|
||||
* No confirmation callback: StationTabState writes through to ChatPreferences, the
|
||||
* way the JavaFX controls did. "Save settings" persists, it does not collect.
|
||||
* The controls stage their edits in the window's draft (see StationTabState); only the
|
||||
* login credentials write through at once.
|
||||
*/
|
||||
@Composable
|
||||
fun StationTab(state: StationTabState) {
|
||||
|
||||
@@ -3,23 +3,25 @@ package kst4contest.view.compose.tabs
|
||||
import kst4contest.controller.On4KstConnectionState
|
||||
import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ChatPreferences
|
||||
import kst4contest.view.compose.SettingsDraft
|
||||
import kst4contest.view.compose.setting
|
||||
|
||||
/**
|
||||
* The Station tab as a typed facade over ChatPreferences.
|
||||
*
|
||||
* Every property writes through immediately, and there is deliberately no apply().
|
||||
* That mirrors the JavaFX window it replaces: its controls each wrote into
|
||||
* ChatPreferences the moment they changed, and its two buttons only persisted
|
||||
* ("Save settings" calls writePreferencesToXmlFile) or closed the window
|
||||
* ("Apply/Close prefs" just hides it). Buffering edits until a confirmation would
|
||||
* change behaviour an operator may rely on: today a changed setting takes effect
|
||||
* in the running session at once, whether or not it is ever written to disk.
|
||||
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
|
||||
* applies them (Apply/Close prefs, Save settings). The login credentials are the exception
|
||||
* and still write through at once: callsign, names, locator, password and both chat
|
||||
* categories. They are locked while a session exists ([loginFieldsEnabled]), and without
|
||||
* one they take effect at the next login anyway, so holding them back would only make
|
||||
* Connect use something other than what the tab shows.
|
||||
*
|
||||
* The property list was derived mechanically from the setters the JavaFX tab
|
||||
* called, intersected with the setters ChatPreferences declares.
|
||||
*/
|
||||
class StationTabState(
|
||||
private val prefs: ChatPreferences,
|
||||
private val draft: SettingsDraft,
|
||||
/**
|
||||
* Applies the chat category to the running session. Passed in rather than taken
|
||||
* from a ChatController so the rules stay testable without building a controller,
|
||||
@@ -43,45 +45,25 @@ class StationTabState(
|
||||
get() = prefs.getLoginChatCategorySecond()
|
||||
set(value) { prefs.setLoginChatCategorySecond(value) }
|
||||
|
||||
var stn_antennaBeamWidthDeg: Double
|
||||
get() = prefs.getStn_antennaBeamWidthDeg()
|
||||
set(value) { prefs.setStn_antennaBeamWidthDeg(value) }
|
||||
var stn_antennaBeamWidthDeg: Double by draft.setting({ prefs.getStn_antennaBeamWidthDeg() }) { prefs.setStn_antennaBeamWidthDeg(it) }
|
||||
|
||||
var stn_bandActive10G: Boolean
|
||||
get() = prefs.isStn_bandActive10G()
|
||||
set(value) { prefs.setStn_bandActive10G(value) }
|
||||
var stn_bandActive10G: Boolean by draft.setting({ prefs.isStn_bandActive10G() }) { prefs.setStn_bandActive10G(it) }
|
||||
|
||||
var stn_bandActive1240: Boolean
|
||||
get() = prefs.isStn_bandActive1240()
|
||||
set(value) { prefs.setStn_bandActive1240(value) }
|
||||
var stn_bandActive1240: Boolean by draft.setting({ prefs.isStn_bandActive1240() }) { prefs.setStn_bandActive1240(it) }
|
||||
|
||||
var stn_bandActive144: Boolean
|
||||
get() = prefs.isStn_bandActive144()
|
||||
set(value) { prefs.setStn_bandActive144(value) }
|
||||
var stn_bandActive144: Boolean by draft.setting({ prefs.isStn_bandActive144() }) { prefs.setStn_bandActive144(it) }
|
||||
|
||||
var stn_bandActive2300: Boolean
|
||||
get() = prefs.isStn_bandActive2300()
|
||||
set(value) { prefs.setStn_bandActive2300(value) }
|
||||
var stn_bandActive2300: Boolean by draft.setting({ prefs.isStn_bandActive2300() }) { prefs.setStn_bandActive2300(it) }
|
||||
|
||||
var stn_bandActive3400: Boolean
|
||||
get() = prefs.isStn_bandActive3400()
|
||||
set(value) { prefs.setStn_bandActive3400(value) }
|
||||
var stn_bandActive3400: Boolean by draft.setting({ prefs.isStn_bandActive3400() }) { prefs.setStn_bandActive3400(it) }
|
||||
|
||||
var stn_bandActive432: Boolean
|
||||
get() = prefs.isStn_bandActive432()
|
||||
set(value) { prefs.setStn_bandActive432(value) }
|
||||
var stn_bandActive432: Boolean by draft.setting({ prefs.isStn_bandActive432() }) { prefs.setStn_bandActive432(it) }
|
||||
|
||||
var stn_bandActive50: Boolean
|
||||
get() = prefs.isStn_bandActive50()
|
||||
set(value) { prefs.setStn_bandActive50(value) }
|
||||
var stn_bandActive50: Boolean by draft.setting({ prefs.isStn_bandActive50() }) { prefs.setStn_bandActive50(it) }
|
||||
|
||||
var stn_bandActive5600: Boolean
|
||||
get() = prefs.isStn_bandActive5600()
|
||||
set(value) { prefs.setStn_bandActive5600(value) }
|
||||
var stn_bandActive5600: Boolean by draft.setting({ prefs.isStn_bandActive5600() }) { prefs.setStn_bandActive5600(it) }
|
||||
|
||||
var stn_bandActive70: Boolean
|
||||
get() = prefs.isStn_bandActive70()
|
||||
set(value) { prefs.setStn_bandActive70(value) }
|
||||
var stn_bandActive70: Boolean by draft.setting({ prefs.isStn_bandActive70() }) { prefs.setStn_bandActive70(it) }
|
||||
|
||||
var stn_loginCallSign: String
|
||||
get() = prefs.getStn_loginCallSign()
|
||||
@@ -103,57 +85,31 @@ class StationTabState(
|
||||
get() = prefs.getStn_loginPassword()
|
||||
set(value) { prefs.setStn_loginPassword(value) }
|
||||
|
||||
var stn_maxQRBDefault: Double
|
||||
get() = prefs.getStn_maxQRBDefault()
|
||||
set(value) { prefs.setStn_maxQRBDefault(value) }
|
||||
var stn_maxQRBDefault: Double by draft.setting({ prefs.getStn_maxQRBDefault() }) { prefs.setStn_maxQRBDefault(it) }
|
||||
|
||||
var stn_on4kstServersDns: String
|
||||
get() = prefs.getStn_on4kstServersDns()
|
||||
set(value) { prefs.setStn_on4kstServersDns(value) }
|
||||
var stn_on4kstServersDns: String by draft.setting({ prefs.getStn_on4kstServersDns() }) { prefs.setStn_on4kstServersDns(it) }
|
||||
|
||||
var stn_on4kstServersPort: Int
|
||||
get() = prefs.getStn_on4kstServersPort()
|
||||
set(value) { prefs.setStn_on4kstServersPort(value) }
|
||||
var stn_on4kstServersPort: Int by draft.setting({ prefs.getStn_on4kstServersPort() }) { prefs.setStn_on4kstServersPort(it) }
|
||||
|
||||
var stn_pathAnalysisDefaultTargetAntennaGainDbi: Double
|
||||
get() = prefs.getStn_pathAnalysisDefaultTargetAntennaGainDbi()
|
||||
set(value) { prefs.setStn_pathAnalysisDefaultTargetAntennaGainDbi(value) }
|
||||
var stn_pathAnalysisDefaultTargetAntennaGainDbi: Double by draft.setting({ prefs.getStn_pathAnalysisDefaultTargetAntennaGainDbi() }) { prefs.setStn_pathAnalysisDefaultTargetAntennaGainDbi(it) }
|
||||
|
||||
var stn_pathAnalysisDefaultTargetTxPowerWatts: Double
|
||||
get() = prefs.getStn_pathAnalysisDefaultTargetTxPowerWatts()
|
||||
set(value) { prefs.setStn_pathAnalysisDefaultTargetTxPowerWatts(value) }
|
||||
var stn_pathAnalysisDefaultTargetTxPowerWatts: Double by draft.setting({ prefs.getStn_pathAnalysisDefaultTargetTxPowerWatts() }) { prefs.setStn_pathAnalysisDefaultTargetTxPowerWatts(it) }
|
||||
|
||||
var stn_pathAnalysisDemRootDirectory: String
|
||||
get() = prefs.getStn_pathAnalysisDemRootDirectory()
|
||||
set(value) { prefs.setStn_pathAnalysisDemRootDirectory(value) }
|
||||
var stn_pathAnalysisDemRootDirectory: String by draft.setting({ prefs.getStn_pathAnalysisDemRootDirectory() }) { prefs.setStn_pathAnalysisDemRootDirectory(it) }
|
||||
|
||||
var stn_pathAnalysisOwnAntennaGainDbi: Double
|
||||
get() = prefs.getStn_pathAnalysisOwnAntennaGainDbi()
|
||||
set(value) { prefs.setStn_pathAnalysisOwnAntennaGainDbi(value) }
|
||||
var stn_pathAnalysisOwnAntennaGainDbi: Double by draft.setting({ prefs.getStn_pathAnalysisOwnAntennaGainDbi() }) { prefs.setStn_pathAnalysisOwnAntennaGainDbi(it) }
|
||||
|
||||
var stn_pathAnalysisOwnAntennaHeightMeters: Double
|
||||
get() = prefs.getStn_pathAnalysisOwnAntennaHeightMeters()
|
||||
set(value) { prefs.setStn_pathAnalysisOwnAntennaHeightMeters(value) }
|
||||
var stn_pathAnalysisOwnAntennaHeightMeters: Double by draft.setting({ prefs.getStn_pathAnalysisOwnAntennaHeightMeters() }) { prefs.setStn_pathAnalysisOwnAntennaHeightMeters(it) }
|
||||
|
||||
var stn_pathAnalysisOwnTxPowerWatts: Double
|
||||
get() = prefs.getStn_pathAnalysisOwnTxPowerWatts()
|
||||
set(value) { prefs.setStn_pathAnalysisOwnTxPowerWatts(value) }
|
||||
var stn_pathAnalysisOwnTxPowerWatts: Double by draft.setting({ prefs.getStn_pathAnalysisOwnTxPowerWatts() }) { prefs.setStn_pathAnalysisOwnTxPowerWatts(it) }
|
||||
|
||||
var stn_pstRotatorEnabled: Boolean
|
||||
get() = prefs.isStn_pstRotatorEnabled()
|
||||
set(value) { prefs.setStn_pstRotatorEnabled(value) }
|
||||
var stn_pstRotatorEnabled: Boolean by draft.setting({ prefs.isStn_pstRotatorEnabled() }) { prefs.setStn_pstRotatorEnabled(it) }
|
||||
|
||||
var stn_pstRotatorHost: String
|
||||
get() = prefs.getStn_pstRotatorHost()
|
||||
set(value) { prefs.setStn_pstRotatorHost(value) }
|
||||
var stn_pstRotatorHost: String by draft.setting({ prefs.getStn_pstRotatorHost() }) { prefs.setStn_pstRotatorHost(it) }
|
||||
|
||||
var stn_pstRotatorPort: Int
|
||||
get() = prefs.getStn_pstRotatorPort()
|
||||
set(value) { prefs.setStn_pstRotatorPort(value) }
|
||||
var stn_pstRotatorPort: Int by draft.setting({ prefs.getStn_pstRotatorPort() }) { prefs.setStn_pstRotatorPort(it) }
|
||||
|
||||
var stn_qtfDefault: Double
|
||||
get() = prefs.getStn_qtfDefault()
|
||||
set(value) { prefs.setStn_qtfDefault(value) }
|
||||
var stn_qtfDefault: Double by draft.setting({ prefs.getStn_qtfDefault() }) { prefs.setStn_qtfDefault(it) }
|
||||
|
||||
/**
|
||||
* The chat category is held twice: in ChatPreferences and in ChatController.
|
||||
@@ -170,9 +126,7 @@ class StationTabState(
|
||||
* pickers it governs. It decides what the Connect button does, so it belongs with
|
||||
* the login settings and not with the display options.
|
||||
*/
|
||||
var loginToSecondChatEnabled: Boolean
|
||||
get() = prefs.isLoginToSecondChatEnabled()
|
||||
set(value) { prefs.setLoginToSecondChatEnabled(value) }
|
||||
var loginToSecondChatEnabled: Boolean by draft.setting({ prefs.isLoginToSecondChatEnabled() }) { prefs.setLoginToSecondChatEnabled(it) }
|
||||
|
||||
/**
|
||||
* Whether the login fields may be edited. The JavaFX window disabled callsign,
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import kst4contest.model.Band
|
||||
import kst4contest.model.ChatPreferences
|
||||
|
||||
/**
|
||||
* Corrects stored settings the settings window cannot show as they are, once, before the
|
||||
* window is built.
|
||||
*
|
||||
* These writes go to ChatPreferences at once and not through the draft: they are no edit of
|
||||
* the operator's, there is nothing to apply or discard, and they are what the JavaFX window
|
||||
* did while building its panes. They used to happen inside the tab states -- one during a
|
||||
* read in composition, one in a constructor -- where a write to the preferences is easy to
|
||||
* miss and runs whenever Compose decides to read. Here they run at one known moment.
|
||||
*
|
||||
* - The fallback band of the DX cluster is stored as a prefix string; one no band has is
|
||||
* repaired to 144 (Kst4ContestApplication:11349 in the JavaFX window).
|
||||
* - The Win-Test broadcast address is replaced by the detected subnet broadcast address
|
||||
* while it is still the catch-all default 255.255.255.255. An address the operator or a
|
||||
* previous detection chose is left alone, and a failed detection keeps the default:
|
||||
* guessing an address would send SKED packets somewhere nobody is listening.
|
||||
*
|
||||
* @param detectWintestBroadcastAddress the detected subnet broadcast address, or null when
|
||||
* none could be determined; maps to detectPreferredWintestBroadcastAddress()
|
||||
*/
|
||||
fun repairStoredSettings(prefs: ChatPreferences, detectWintestBroadcastAddress: () -> String?) {
|
||||
if (Band.fromPrefix(prefs.getNotify_optionalFrequencyPrefix().get()) == null) {
|
||||
prefs.setNotify_optionalFrequencyPrefix(Band.B_144.prefix)
|
||||
}
|
||||
|
||||
if (prefs.getLogsynch_wintestNetworkBroadcastAddress() == LogSynchTabState.DEFAULT_BROADCAST_ADDRESS) {
|
||||
val detected = try {
|
||||
detectWintestBroadcastAddress()
|
||||
} catch (probeFailure: RuntimeException) {
|
||||
null
|
||||
}
|
||||
if (!detected.isNullOrBlank()) {
|
||||
prefs.setLogsynch_wintestNetworkBroadcastAddress(detected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,7 @@ import kst4contest.view.compose.Form
|
||||
* QRG update needs an enabled source *and* valid incoming RadioInfo or Win-Test STATUS
|
||||
* data, so a checked box must not read as "my QRG is being kept current".
|
||||
*
|
||||
* No confirmation callback: TrxSynchTabState writes through to ChatPreferences, the way
|
||||
* the JavaFX controls did.
|
||||
* The controls stage their edits in the window's draft (see TrxSynchTabState).
|
||||
*/
|
||||
@Composable
|
||||
fun TrxSynchTab(state: TrxSynchTabState) {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import kst4contest.model.ChatPreferences
|
||||
import kst4contest.view.compose.SettingsDraft
|
||||
import kst4contest.view.compose.setting
|
||||
|
||||
/**
|
||||
* The TRX synch tab as a typed facade over ChatPreferences.
|
||||
*
|
||||
* Every property writes through immediately and there is deliberately no apply(), the
|
||||
* way the JavaFX controls behaved.
|
||||
* Edits are staged in the window's [SettingsDraft] and take effect when the operator
|
||||
* applies them.
|
||||
*
|
||||
* The tab carries the two sources that may update the own QRG. Both live under the
|
||||
* logsynch_ and trxSynch_ prefixes, but the JavaFX window put both controls on this
|
||||
@@ -19,33 +21,37 @@ import kst4contest.model.ChatPreferences
|
||||
* cannot read a checked box as "my QRG is being kept current".
|
||||
*
|
||||
* @param applyOwnQrgFollower attaches the own-QRG follower when any source is enabled
|
||||
* and detaches it otherwise; maps to attachOwnQrgFollower()/detachOwnQrgFollower()
|
||||
* and detaches it otherwise, once per apply however many sources changed; maps to attachOwnQrgFollower()/detachOwnQrgFollower()
|
||||
* plus the tooltip on the own-QRG field. Not a ChatPreferences setter, so no coverage
|
||||
* test can see it, and passed in so the rules stay testable without a controller.
|
||||
*/
|
||||
class TrxSynchTabState(
|
||||
private val prefs: ChatPreferences,
|
||||
private val draft: SettingsDraft,
|
||||
private val applyOwnQrgFollower: (Boolean) -> Unit,
|
||||
) {
|
||||
|
||||
var trxSynch_ucxLogUDPListenerEnabled: Boolean
|
||||
get() = prefs.isTrxSynch_ucxLogUDPListenerEnabled()
|
||||
set(value) {
|
||||
prefs.setTrxSynch_ucxLogUDPListenerEnabled(value)
|
||||
applyOwnQrgFollower(anyQrgSourceEnabled)
|
||||
}
|
||||
var trxSynch_ucxLogUDPListenerEnabled: Boolean by draft.setting(
|
||||
{ prefs.isTrxSynch_ucxLogUDPListenerEnabled() },
|
||||
effectKey = OWN_QRG_FOLLOWER,
|
||||
effect = ::applyFollower,
|
||||
) { prefs.setTrxSynch_ucxLogUDPListenerEnabled(it) }
|
||||
|
||||
var logsynch_wintestQrgSyncEnabled: Boolean
|
||||
get() = prefs.isLogsynch_wintestQrgSyncEnabled()
|
||||
set(value) {
|
||||
prefs.setLogsynch_wintestQrgSyncEnabled(value)
|
||||
applyOwnQrgFollower(anyQrgSourceEnabled)
|
||||
}
|
||||
var logsynch_wintestQrgSyncEnabled: Boolean by draft.setting(
|
||||
{ prefs.isLogsynch_wintestQrgSyncEnabled() },
|
||||
effectKey = OWN_QRG_FOLLOWER,
|
||||
effect = ::applyFollower,
|
||||
) { prefs.setLogsynch_wintestQrgSyncEnabled(it) }
|
||||
|
||||
/** Use the Win-Test pass frequency from STATUS instead of the own QRG. */
|
||||
var logsynch_wintestUsePassQrg: Boolean
|
||||
get() = prefs.isLogsynch_wintestUsePassQrg()
|
||||
set(value) { prefs.setLogsynch_wintestUsePassQrg(value) }
|
||||
var logsynch_wintestUsePassQrg: Boolean by draft.setting(
|
||||
{ prefs.isLogsynch_wintestUsePassQrg() },
|
||||
) { prefs.setLogsynch_wintestUsePassQrg(it) }
|
||||
|
||||
// Staged trimmed, so the draft holds what will be stored.
|
||||
private var stationFilter: String by draft.setting(
|
||||
{ prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1() },
|
||||
) { prefs.setLogsynch_wintestNetworkStationNameOfWintestClient1(it) }
|
||||
|
||||
/**
|
||||
* Win-Test station name filter. An empty filter accepts every station, which is
|
||||
@@ -53,8 +59,13 @@ class TrxSynchTabState(
|
||||
* way the JavaFX field committed it.
|
||||
*/
|
||||
var logsynch_wintestNetworkStationNameOfWintestClient1: String
|
||||
get() = prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1()
|
||||
set(value) { prefs.setLogsynch_wintestNetworkStationNameOfWintestClient1(value.trim()) }
|
||||
get() = stationFilter
|
||||
set(value) { stationFilter = value.trim() }
|
||||
|
||||
/* Read from the stored values: it runs on apply, after both switches are written. */
|
||||
private fun applyFollower() {
|
||||
applyOwnQrgFollower(prefs.isTrxSynch_ucxLogUDPListenerEnabled() || prefs.isLogsynch_wintestQrgSyncEnabled())
|
||||
}
|
||||
|
||||
/**
|
||||
* True when at least one source may update the own QRG. One source switched off is
|
||||
@@ -74,5 +85,8 @@ class TrxSynchTabState(
|
||||
* would read whichever one the tab happened to use.
|
||||
*/
|
||||
const val QRG_SOURCE_HINT_KEY: String = "trxSynch.hint"
|
||||
|
||||
/** The effect key both QRG sources share. */
|
||||
private const val OWN_QRG_FOLLOWER = "ownQrgFollower"
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,7 @@ class DrawingLayerRegressionTest(@TempDir private val tempDir: Path) {
|
||||
fun aTypedSettingAppearsOnScreenAndNotOnlyInThePreferences() = runComposeUiTest {
|
||||
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||
prefs.setMessageHandling_autoAnswerTextMainCat("")
|
||||
val state = MessageHandlingTabState(prefs)
|
||||
val state = MessageHandlingTabState(prefs, ImmediateDraft())
|
||||
|
||||
// The real tab, not a field in isolation: the fault was in the wiring between them.
|
||||
setContent(themed { MessageHandlingTab(state) })
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
/**
|
||||
* A draft that applies every change the moment it is staged.
|
||||
*
|
||||
* For the tests that pin a tab state's own rules -- validation, normalisation, which effect
|
||||
* an edit triggers -- without the draft in between. The draft itself is pinned by
|
||||
* SettingsDraftTest, and that every drafted setting really waits for Apply by
|
||||
* DraftedSettingsTableTest. A failed write or effect throws here, so a test cannot pass
|
||||
* over one unnoticed.
|
||||
*/
|
||||
internal class ImmediateDraft : SettingsDraft() {
|
||||
|
||||
override fun <T> stage(
|
||||
key: String,
|
||||
value: T,
|
||||
live: () -> T,
|
||||
write: (T) -> Unit,
|
||||
effectKey: String?,
|
||||
effect: (() -> Unit)?,
|
||||
) {
|
||||
super.stage(key, value, live, write, effectKey, effect)
|
||||
val outcome = apply()
|
||||
check(outcome.isClean) { "apply failed: ${outcome.failures}" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package kst4contest.view.compose
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
/**
|
||||
* The settings draft: edits wait in it, and only apply() writes them and runs their effects.
|
||||
*/
|
||||
class SettingsDraftTest {
|
||||
|
||||
/** A stored setting with a log of what happened to it. */
|
||||
private class Stored(var value: Int, private val log: MutableList<String>, private val name: String) {
|
||||
fun write(v: Int) {
|
||||
value = v
|
||||
log += "write $name=$v"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a staged value is shown but not written`() {
|
||||
val log = mutableListOf<String>()
|
||||
val port = Stored(9871, log, "port")
|
||||
val draft = SettingsDraft()
|
||||
|
||||
draft.stage("port", 9872, live = { port.value }, write = port::write)
|
||||
|
||||
assertEquals(9872, draft.valueOf("port") { port.value })
|
||||
assertEquals(9871, port.value, "a staged value reached the stored setting")
|
||||
assertTrue(draft.hasPendingChanges)
|
||||
assertTrue(log.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unstaged key reads the stored value`() {
|
||||
val draft = SettingsDraft()
|
||||
|
||||
assertEquals(42, draft.valueOf("anything") { 42 })
|
||||
assertFalse(draft.hasPendingChanges)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `apply writes and runs the effect, then nothing is pending`() {
|
||||
val log = mutableListOf<String>()
|
||||
val port = Stored(9871, log, "port")
|
||||
val draft = SettingsDraft()
|
||||
|
||||
draft.stage("port", 9872, live = { port.value }, write = port::write, effect = { log += "restart" })
|
||||
draft.apply()
|
||||
|
||||
assertEquals(9872, port.value)
|
||||
assertEquals(listOf("write port=9872", "restart"), log)
|
||||
assertFalse(draft.hasPendingChanges)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `discard writes nothing and runs nothing`() {
|
||||
val log = mutableListOf<String>()
|
||||
val port = Stored(9871, log, "port")
|
||||
val draft = SettingsDraft()
|
||||
|
||||
draft.stage("port", 9872, live = { port.value }, write = port::write, effect = { log += "restart" })
|
||||
draft.discard()
|
||||
|
||||
assertEquals(9871, port.value)
|
||||
assertTrue(log.isEmpty())
|
||||
assertFalse(draft.hasPendingChanges)
|
||||
assertEquals(9871, draft.valueOf("port") { port.value })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `staging the stored value again leaves nothing pending`() {
|
||||
val log = mutableListOf<String>()
|
||||
val flag = Stored(0, log, "flag")
|
||||
val draft = SettingsDraft()
|
||||
|
||||
draft.stage("flag", 1, live = { flag.value }, write = flag::write, effect = { log += "effect" })
|
||||
draft.stage("flag", 0, live = { flag.value }, write = flag::write, effect = { log += "effect" })
|
||||
|
||||
assertFalse(draft.hasPendingChanges, "a box ticked and unticked again is no change")
|
||||
draft.apply()
|
||||
assertTrue(log.isEmpty(), "apply ran something for a setting that did not change")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the last staged value of a key wins`() {
|
||||
val log = mutableListOf<String>()
|
||||
val port = Stored(1, log, "port")
|
||||
val draft = SettingsDraft()
|
||||
|
||||
draft.stage("port", 2, live = { port.value }, write = port::write)
|
||||
draft.stage("port", 3, live = { port.value }, write = port::write)
|
||||
draft.apply()
|
||||
|
||||
assertEquals(listOf("write port=3"), log, "one write per setting, with the final value")
|
||||
}
|
||||
|
||||
/*
|
||||
* Every write lands before the first effect, so an effect sees the finished state: a
|
||||
* listener restarted between "enabled" and "port" would bind the old port.
|
||||
*/
|
||||
@Test
|
||||
fun `all writes come before any effect, in the order first changed`() {
|
||||
val log = mutableListOf<String>()
|
||||
val a = Stored(0, log, "a")
|
||||
val b = Stored(0, log, "b")
|
||||
val draft = SettingsDraft()
|
||||
|
||||
draft.stage("b", 1, live = { b.value }, write = b::write, effect = { log += "effect b" })
|
||||
draft.stage("a", 1, live = { a.value }, write = a::write, effect = { log += "effect a" })
|
||||
draft.stage("b", 2, live = { b.value }, write = b::write, effect = { log += "effect b" })
|
||||
draft.apply()
|
||||
|
||||
assertEquals(listOf("write b=2", "write a=1", "effect b", "effect a"), log)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `settings sharing an effect key run that effect once`() {
|
||||
val log = mutableListOf<String>()
|
||||
val enabled = Stored(0, log, "enabled")
|
||||
val port = Stored(9871, log, "port")
|
||||
val draft = SettingsDraft()
|
||||
|
||||
draft.stage("enabled", 1, live = { enabled.value }, write = enabled::write,
|
||||
effectKey = "listener", effect = { log += "restart" })
|
||||
draft.stage("port", 9872, live = { port.value }, write = port::write,
|
||||
effectKey = "listener", effect = { log += "restart" })
|
||||
draft.apply()
|
||||
|
||||
assertEquals(listOf("write enabled=1", "write port=9872", "restart"), log)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failing effect is reported and the other effects still run`() {
|
||||
val draft = SettingsDraft()
|
||||
val log = mutableListOf<String>()
|
||||
var a = 0
|
||||
var b = 0
|
||||
|
||||
draft.stage("a", 1, live = { a }, write = { a = it }, effect = { error("socket refused") })
|
||||
draft.stage("b", 1, live = { b }, write = { b = it }, effect = { log += "timer" })
|
||||
val outcome = draft.apply()
|
||||
|
||||
assertEquals(1, a, "the write before the failing effect happened")
|
||||
assertEquals(listOf("timer"), log, "one failing effect stopped the others")
|
||||
assertEquals(listOf("a: socket refused"), outcome.failedEffects)
|
||||
assertTrue(outcome.failedWrites.isEmpty())
|
||||
assertFalse(draft.hasPendingChanges, "a stored setting would be applied a second time")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failing write stays pending, skips its effect, and the rest is applied`() {
|
||||
val draft = SettingsDraft()
|
||||
val log = mutableListOf<String>()
|
||||
var b = 0
|
||||
|
||||
draft.stage("a", 1, live = { 0 }, write = { error("disk full") }, effect = { log += "effect a" })
|
||||
draft.stage("b", 1, live = { b }, write = { b = it }, effect = { log += "effect b" })
|
||||
val outcome = draft.apply()
|
||||
|
||||
assertEquals(1, b)
|
||||
assertEquals(listOf("effect b"), log, "an effect ran for a value that was never stored")
|
||||
assertEquals(listOf("a: disk full"), outcome.failedWrites)
|
||||
assertFalse(outcome.isClean)
|
||||
assertTrue(draft.hasPendingChanges, "the failed setting was dropped")
|
||||
assertEquals(1, draft.valueOf("a") { 0 })
|
||||
}
|
||||
|
||||
private class TwoSettings(draft: SettingsDraft, val log: MutableList<String>) {
|
||||
var storedA = 1
|
||||
var storedB = 1
|
||||
var a: Int by draft.setting({ storedA }) { storedA = it; log += "a=$it" }
|
||||
var b: Int by draft.setting({ storedB }, effect = { log += "effect b" }) { storedB = it; log += "b=$it" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a drafted property reads through the draft and stages its writes`() {
|
||||
val draft = SettingsDraft()
|
||||
val tab = TwoSettings(draft, mutableListOf())
|
||||
|
||||
tab.a = 7
|
||||
|
||||
assertEquals(7, tab.a)
|
||||
assertEquals(1, tab.storedA)
|
||||
assertEquals(1, tab.b, "staging one property touched another")
|
||||
draft.apply()
|
||||
assertEquals(7, tab.storedA)
|
||||
assertEquals(listOf("a=7"), tab.log)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a drafted property carries its effect`() {
|
||||
val draft = SettingsDraft()
|
||||
val tab = TwoSettings(draft, mutableListOf())
|
||||
|
||||
tab.b = 3
|
||||
draft.apply()
|
||||
|
||||
assertEquals(listOf("b=3", "effect b"), tab.log)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `typed entries are committed on request and report their refusals`() {
|
||||
val draft = SettingsDraft()
|
||||
var stored = 0
|
||||
val entry = draft.registerTypedEntry {
|
||||
draft.stage("k", 5, live = { stored }, write = { stored = it })
|
||||
null
|
||||
}
|
||||
draft.registerTypedEntry { "Port: \"98x\" is not a valid entry." }
|
||||
|
||||
val refusals = draft.commitTypedEntries()
|
||||
|
||||
assertEquals(listOf("Port: \"98x\" is not a valid entry."), refusals)
|
||||
assertTrue(draft.hasPendingChanges, "the accepted entry was not staged")
|
||||
assertEquals(0, stored, "committing an entry applied it")
|
||||
|
||||
draft.discard()
|
||||
entry.close()
|
||||
draft.commitTypedEntries()
|
||||
assertFalse(draft.hasPendingChanges, "a removed field was still committed")
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import kst4contest.model.ChatPreferences
|
||||
import kst4contest.view.compose.ImmediateDraft
|
||||
import kst4contest.view.compose.SettingsDraft
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
@@ -10,9 +12,13 @@ import java.nio.file.Path
|
||||
|
||||
class AirscoutTabStateTest(@TempDir private val tempDir: Path) {
|
||||
|
||||
private inner class Session {
|
||||
/*
|
||||
* The rules below are pinned with a draft that applies at once; what the draft itself
|
||||
* changes is pinned by the two tests that pass a real one.
|
||||
*/
|
||||
private inner class Session(val draft: SettingsDraft = ImmediateDraft()) {
|
||||
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||
val state by lazy { AirscoutTabState(prefs) }
|
||||
val state by lazy { AirscoutTabState(prefs, draft) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -34,7 +40,39 @@ class AirscoutTabStateTest(@TempDir private val tempDir: Path) {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `writing reaches the preferences at once, without a confirmation step`() {
|
||||
fun `an edit waits for apply`() {
|
||||
val s = Session(SettingsDraft())
|
||||
s.prefs.setAirScout_asCommunicationPort(9872)
|
||||
|
||||
s.state.airScout_asCommunicationPort = 9873
|
||||
s.state.airScout_asServerNameString = " AS3 "
|
||||
|
||||
assertEquals(9872, s.prefs.getAirScout_asCommunicationPort(), "an edit took effect before Apply")
|
||||
assertEquals(9873, s.state.airScout_asCommunicationPort, "the tab shows the edit")
|
||||
assertEquals("AS3", s.state.airScout_asServerNameString, "the draft holds the trimmed value")
|
||||
|
||||
s.draft.apply()
|
||||
|
||||
assertEquals(9873, s.prefs.getAirScout_asCommunicationPort())
|
||||
assertEquals("AS3", s.prefs.getAirScout_asServerNameString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a discarded edit changes nothing, and an invalid one never enters the draft`() {
|
||||
val s = Session(SettingsDraft())
|
||||
s.prefs.setAirScout_asBandString("1440000")
|
||||
|
||||
s.state.airScout_asBandString = "-1"
|
||||
assertFalse(s.draft.hasPendingChanges, "a refused value reached the draft")
|
||||
|
||||
s.state.airScout_asBandString = "4320000"
|
||||
s.draft.discard()
|
||||
|
||||
assertEquals("1440000", s.prefs.getAirScout_asBandString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `writing reaches the preferences on apply`() {
|
||||
val s = Session()
|
||||
|
||||
s.state.airScout_asUDPListenerEnabled = true
|
||||
|
||||
@@ -2,6 +2,8 @@ package kst4contest.view.compose.tabs
|
||||
|
||||
import kst4contest.controller.ChatController
|
||||
import kst4contest.model.ChatPreferences
|
||||
import kst4contest.view.compose.ImmediateDraft
|
||||
import kst4contest.view.compose.SettingsDraft
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
@@ -13,13 +15,21 @@ import java.nio.file.Path
|
||||
|
||||
class BeaconTabStateTest(@TempDir private val tempDir: Path) {
|
||||
|
||||
private inner class Session(private val templateRefusal: String? = null) {
|
||||
/*
|
||||
* The rules are pinned with a draft that applies at once; what the draft changes is
|
||||
* pinned by the tests that pass a real one.
|
||||
*/
|
||||
private inner class Session(
|
||||
private val templateRefusal: String? = null,
|
||||
val draft: SettingsDraft = ImmediateDraft(),
|
||||
) {
|
||||
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||
var timerRestarts = 0
|
||||
var validated = mutableListOf<String>()
|
||||
|
||||
fun state() = BeaconTabState(
|
||||
prefs,
|
||||
draft,
|
||||
validateBeaconTemplate = { template ->
|
||||
validated.add(template)
|
||||
templateRefusal?.let { throw IllegalArgumentException(it) }
|
||||
@@ -31,7 +41,46 @@ class BeaconTabStateTest(@TempDir private val tempDir: Path) {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the enable flags write through at once`() {
|
||||
fun `beacon edits wait for apply, and the timer restarts only then`() {
|
||||
val s = Session(draft = SettingsDraft())
|
||||
s.prefs.setBcn_beaconIntervalInMinutesMainCat(5)
|
||||
s.prefs.setBcn_beaconsEnabledMainCat(false)
|
||||
val state = s.state()
|
||||
|
||||
state.bcn_beaconsEnabledMainCat = true
|
||||
assertNull(state.commitBeaconTextMainCat("CQ MYCALL"))
|
||||
assertNull(state.commitBeaconInterval("7"))
|
||||
|
||||
assertFalse(s.prefs.isBcn_beaconsEnabledMainCat(), "an edit took effect before Apply")
|
||||
assertEquals(5, s.prefs.getBcn_beaconIntervalInMinutesMainCat())
|
||||
assertEquals(0, s.timerRestarts, "the timer restarted before Apply")
|
||||
assertEquals(7, state.beaconIntervalMinutes, "the tab shows the edit")
|
||||
assertEquals("CQ MYCALL", state.beaconTextMainCat)
|
||||
|
||||
s.draft.apply()
|
||||
|
||||
assertTrue(s.prefs.isBcn_beaconsEnabledMainCat())
|
||||
assertEquals("CQ MYCALL", s.prefs.getBcn_beaconTextMainCat())
|
||||
assertEquals(7, s.prefs.getBcn_beaconIntervalInMinutesSecondCat())
|
||||
assertEquals(1, s.timerRestarts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `discarded beacon edits leave the timer and the templates alone`() {
|
||||
val s = Session(draft = SettingsDraft())
|
||||
s.prefs.setBcn_beaconTextMainCat("CQ MYCALL")
|
||||
val state = s.state()
|
||||
|
||||
state.commitBeaconTextMainCat("CQ MYCALL MYQRG")
|
||||
state.commitBeaconInterval("9")
|
||||
s.draft.discard()
|
||||
|
||||
assertEquals("CQ MYCALL", s.prefs.getBcn_beaconTextMainCat())
|
||||
assertEquals(0, s.timerRestarts)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the enable flags reach the preferences`() {
|
||||
val s = Session()
|
||||
val state = s.state()
|
||||
|
||||
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import kst4contest.model.Band
|
||||
import kst4contest.model.ChatPreferences
|
||||
import kst4contest.observe.SimpleRoster
|
||||
import kst4contest.view.compose.SettingsDraft
|
||||
import kst4contest.view.i18n.LanguageStore
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNotEquals
|
||||
import org.junit.jupiter.api.DynamicTest
|
||||
import org.junit.jupiter.api.TestFactory
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Every drafted setting of every tab, with a real [SettingsDraft]: unchanged in the
|
||||
* preferences after the edit, changed after apply, unchanged after discard.
|
||||
*
|
||||
* The tab tests pin each tab's rules with a draft that applies at once, so they cannot tell
|
||||
* a setting that goes through the draft from one that writes ChatPreferences directly. This
|
||||
* table can, setter by setter. A setting added to a tab belongs in it; one that is meant to
|
||||
* stay immediate (login credentials, language, colours, actions) belongs in the spec instead.
|
||||
*/
|
||||
class DraftedSettingsTableTest {
|
||||
|
||||
@TempDir
|
||||
lateinit var directory: Path
|
||||
|
||||
/** All drafted tabs over one preferences file and one draft. */
|
||||
private class Fixture(val prefs: ChatPreferences, val monitored: SimpleRoster<String> = SimpleRoster()) {
|
||||
val draft = SettingsDraft()
|
||||
val station = StationTabState(prefs, draft, { }, { })
|
||||
val logSynch = LogSynchTabState(prefs, draft, { }, { })
|
||||
val trx = TrxSynchTabState(prefs, draft) { }
|
||||
val airscout = AirscoutTabState(prefs, draft)
|
||||
val notification = NotificationTabState(prefs, draft, monitored, { }, { null })
|
||||
val shortcuts = ShortcutsTabState(prefs, draft, { }, { })
|
||||
val beacon = BeaconTabState(prefs, draft, { }, { }, "Microwave", "144/432 MHz")
|
||||
val messages = MessageHandlingTabState(prefs, draft) { }
|
||||
val gui = GuiOptionsTabState(prefs, draft, LanguageStore({ "en" }, { }, Locale.UK))
|
||||
}
|
||||
|
||||
/** One drafted setting: how the tab changes it, and how the stored value is read. */
|
||||
private class Case(val name: String, val change: (Fixture) -> Unit, val stored: (Fixture) -> Any?)
|
||||
|
||||
private fun flag(name: String, read: (Fixture) -> Boolean, write: (Fixture, Boolean) -> Unit) =
|
||||
Case(name, { f -> write(f, !read(f)) }, read)
|
||||
|
||||
private fun cases(): List<Case> = listOf(
|
||||
// Station, all but the login credentials
|
||||
Case("station beam width", { it.station.stn_antennaBeamWidthDeg += 1.5 }, { it.prefs.getStn_antennaBeamWidthDeg() }),
|
||||
flag("station 10G", { it.station.stn_bandActive10G }) { f, v -> f.station.stn_bandActive10G = v },
|
||||
flag("station 1240", { it.station.stn_bandActive1240 }) { f, v -> f.station.stn_bandActive1240 = v },
|
||||
flag("station 144", { it.station.stn_bandActive144 }) { f, v -> f.station.stn_bandActive144 = v },
|
||||
flag("station 2300", { it.station.stn_bandActive2300 }) { f, v -> f.station.stn_bandActive2300 = v },
|
||||
flag("station 3400", { it.station.stn_bandActive3400 }) { f, v -> f.station.stn_bandActive3400 = v },
|
||||
flag("station 432", { it.station.stn_bandActive432 }) { f, v -> f.station.stn_bandActive432 = v },
|
||||
flag("station 50", { it.station.stn_bandActive50 }) { f, v -> f.station.stn_bandActive50 = v },
|
||||
flag("station 5600", { it.station.stn_bandActive5600 }) { f, v -> f.station.stn_bandActive5600 = v },
|
||||
flag("station 70", { it.station.stn_bandActive70 }) { f, v -> f.station.stn_bandActive70 = v },
|
||||
Case("station max QRB", { it.station.stn_maxQRBDefault += 1.5 }, { it.prefs.getStn_maxQRBDefault() }),
|
||||
Case("station server host", { it.station.stn_on4kstServersDns += "x" }, { it.prefs.getStn_on4kstServersDns() }),
|
||||
Case("station server port", { it.station.stn_on4kstServersPort += 1 }, { it.prefs.getStn_on4kstServersPort() }),
|
||||
Case("station target gain", { it.station.stn_pathAnalysisDefaultTargetAntennaGainDbi += 1.5 },
|
||||
{ it.prefs.getStn_pathAnalysisDefaultTargetAntennaGainDbi() }),
|
||||
Case("station target power", { it.station.stn_pathAnalysisDefaultTargetTxPowerWatts += 1.5 },
|
||||
{ it.prefs.getStn_pathAnalysisDefaultTargetTxPowerWatts() }),
|
||||
Case("station DEM directory", { it.station.stn_pathAnalysisDemRootDirectory += "x" },
|
||||
{ it.prefs.getStn_pathAnalysisDemRootDirectory() }),
|
||||
Case("station own gain", { it.station.stn_pathAnalysisOwnAntennaGainDbi += 1.5 },
|
||||
{ it.prefs.getStn_pathAnalysisOwnAntennaGainDbi() }),
|
||||
Case("station own height", { it.station.stn_pathAnalysisOwnAntennaHeightMeters += 1.5 },
|
||||
{ it.prefs.getStn_pathAnalysisOwnAntennaHeightMeters() }),
|
||||
Case("station own power", { it.station.stn_pathAnalysisOwnTxPowerWatts += 1.5 },
|
||||
{ it.prefs.getStn_pathAnalysisOwnTxPowerWatts() }),
|
||||
flag("station rotator", { it.station.stn_pstRotatorEnabled }) { f, v -> f.station.stn_pstRotatorEnabled = v },
|
||||
Case("station rotator host", { it.station.stn_pstRotatorHost += "x" }, { it.prefs.getStn_pstRotatorHost() }),
|
||||
Case("station rotator port", { it.station.stn_pstRotatorPort += 1 }, { it.prefs.getStn_pstRotatorPort() }),
|
||||
Case("station QTF", { it.station.stn_qtfDefault += 1.5 }, { it.prefs.getStn_qtfDefault() }),
|
||||
flag("station second chat", { it.station.loginToSecondChatEnabled }) { f, v -> f.station.loginToSecondChatEnabled = v },
|
||||
|
||||
// Log synch, all but the file chooser
|
||||
flag("log file polling", { it.logSynch.logsynch_fileBasedWkdCallInterpreterEnabled }) { f, v ->
|
||||
f.logSynch.logsynch_fileBasedWkdCallInterpreterEnabled = v
|
||||
},
|
||||
flag("log UCX listener", { it.logSynch.logsynch_ucxUDPWkdCallListenerEnabled }) { f, v ->
|
||||
f.logSynch.logsynch_ucxUDPWkdCallListenerEnabled = v
|
||||
},
|
||||
Case("log UCX port", { it.logSynch.logsynch_ucxUDPWkdCallListenerPort += 1 },
|
||||
{ it.prefs.getLogsynch_ucxUDPWkdCallListenerPort() }),
|
||||
flag("log Win-Test listener", { it.logSynch.logsynch_wintestNetworkListenerEnabled }) { f, v ->
|
||||
f.logSynch.logsynch_wintestNetworkListenerEnabled = v
|
||||
},
|
||||
Case("log Win-Test port", { it.logSynch.logsynch_wintestNetworkPort += 1 }, { it.prefs.getLogsynch_wintestNetworkPort() }),
|
||||
Case("log Win-Test station name", { it.logSynch.logsynch_wintestNetworkStationNameOfKST += "x" },
|
||||
{ it.prefs.getLogsynch_wintestNetworkStationNameOfKST() }),
|
||||
Case("log Win-Test broadcast", { it.logSynch.logsynch_wintestNetworkBroadcastAddress = "10.9.8.255" },
|
||||
{ it.prefs.getLogsynch_wintestNetworkBroadcastAddress() }),
|
||||
|
||||
// TRX synch
|
||||
flag("trx UCX QRG", { it.trx.trxSynch_ucxLogUDPListenerEnabled }) { f, v -> f.trx.trxSynch_ucxLogUDPListenerEnabled = v },
|
||||
flag("trx Win-Test QRG", { it.trx.logsynch_wintestQrgSyncEnabled }) { f, v -> f.trx.logsynch_wintestQrgSyncEnabled = v },
|
||||
flag("trx pass QRG", { it.trx.logsynch_wintestUsePassQrg }) { f, v -> f.trx.logsynch_wintestUsePassQrg = v },
|
||||
Case("trx station filter", { it.trx.logsynch_wintestNetworkStationNameOfWintestClient1 += "x" },
|
||||
{ it.prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1() }),
|
||||
|
||||
// Airscout
|
||||
flag("airscout listener", { it.airscout.airScout_asUDPListenerEnabled }) { f, v -> f.airscout.airScout_asUDPListenerEnabled = v },
|
||||
Case("airscout server", { it.airscout.airScout_asServerNameString += "X" }, { it.prefs.getAirScout_asServerNameString() }),
|
||||
Case("airscout client", { it.airscout.airScout_asClientNameString += "X" }, { it.prefs.getAirScout_asClientNameString() }),
|
||||
Case("airscout port", { it.airscout.airScout_asCommunicationPort += 1 }, { it.prefs.getAirScout_asCommunicationPort() }),
|
||||
flag("airscout auto band", { it.airscout.airScout_autoBandSelectionEnabled }) { f, v ->
|
||||
f.airscout.airScout_autoBandSelectionEnabled = v
|
||||
},
|
||||
Case("airscout band", { f -> f.airscout.airScout_asBandString = ((f.airscout.airScout_asBandString.toLong()) + 1).toString() },
|
||||
{ it.prefs.getAirScout_asBandString() }),
|
||||
|
||||
// Notification, all but the test spot
|
||||
flag("sound simple", { it.notification.notify_playSimpleSounds }) { f, v -> f.notification.notify_playSimpleSounds = v },
|
||||
flag("sound CW", { it.notification.notify_playCWCallsignsOnRxedPMs }) { f, v -> f.notification.notify_playCWCallsignsOnRxedPMs = v },
|
||||
flag("sound voice", { it.notification.notify_playVoiceCallsignsOnRxedPMs }) { f, v ->
|
||||
f.notification.notify_playVoiceCallsignsOnRxedPMs = v
|
||||
},
|
||||
flag("band upgrade hint", { it.notification.notify_bandUpgradeHintOnLogEnabled }) { f, v ->
|
||||
f.notification.notify_bandUpgradeHintOnLogEnabled = v
|
||||
},
|
||||
flag("band upgrade boost", { it.notification.notify_bandUpgradePriorityBoostEnabled }) { f, v ->
|
||||
f.notification.notify_bandUpgradePriorityBoostEnabled = v
|
||||
},
|
||||
Case("cluster server", { f -> f.notification.enableDxClusterServer(!f.notification.notify_dxClusterServerEnabled) },
|
||||
{ it.prefs.isNotify_dxClusterServerEnabled() }),
|
||||
Case("cluster port", { f -> f.notification.commitDxClusterServerPort((f.notification.notify_dxclusterServerPort + 1).toString()) },
|
||||
{ it.prefs.getNotify_dxclusterServerPort() }),
|
||||
Case("cluster spotter", { f ->
|
||||
f.notification.commitSpotterCallSign(if (f.notification.notify_DXCSrv_SpottersCallSign == "DL1ABC") "DO5AMF" else "DL1ABC")
|
||||
}, { it.prefs.getNotify_DXCSrv_SpottersCallSign().get() }),
|
||||
Case("cluster fallback band", { f ->
|
||||
f.notification.notify_optionalFrequencyPrefix =
|
||||
if (f.notification.notify_optionalFrequencyPrefix == Band.B_432) Band.B_144 else Band.B_432
|
||||
}, { it.prefs.getNotify_optionalFrequencyPrefix().get() }),
|
||||
Case("monitoring list", { it.notification.addMonitoredCallSign("DL1ABC") }, { it.monitored.snapshot() }),
|
||||
|
||||
// Shortcuts
|
||||
Case("shortcut buttons", { f ->
|
||||
f.shortcuts.shortcuts.addAtTop("cq")
|
||||
f.shortcuts.commitShortcuts()
|
||||
}, { it.prefs.getLst_txtShortCutBtnList().snapshot() }),
|
||||
Case("text snippets", { f ->
|
||||
f.shortcuts.snippets.addAtTop("73")
|
||||
f.shortcuts.commitSnippets()
|
||||
}, { it.prefs.getLst_txtSnipList().snapshot() }),
|
||||
|
||||
// Beacon
|
||||
flag("beacon main", { it.beacon.bcn_beaconsEnabledMainCat }) { f, v -> f.beacon.bcn_beaconsEnabledMainCat = v },
|
||||
flag("beacon second", { it.beacon.bcn_beaconsEnabledSecondCat }) { f, v -> f.beacon.bcn_beaconsEnabledSecondCat = v },
|
||||
Case("beacon text main", { it.beacon.commitBeaconTextMainCat(it.beacon.beaconTextMainCat + " x") },
|
||||
{ it.prefs.getBcn_beaconTextMainCat() }),
|
||||
Case("beacon text second", { it.beacon.commitBeaconTextSecondCat(it.beacon.beaconTextSecondCat + " x") },
|
||||
{ it.prefs.getBcn_beaconTextSecondCat() }),
|
||||
Case("beacon interval", { it.beacon.commitBeaconInterval((it.beacon.beaconIntervalMinutes + 1).toString()) },
|
||||
{ it.prefs.getBcn_beaconIntervalInMinutesMainCat() }),
|
||||
|
||||
// Message handling
|
||||
flag("auto answer", { it.messages.autoAnswerEnabled }) { f, v -> f.messages.autoAnswerEnabled = v },
|
||||
Case("auto answer text", { it.messages.autoAnswerText += " x" }, { it.prefs.getMessageHandling_autoAnswerTextMainCat() }),
|
||||
flag("QRG answer", { it.messages.autoAnswerToQRGRequestEnabled }) { f, v -> f.messages.autoAnswerToQRGRequestEnabled = v },
|
||||
flag("debug to file", { it.messages.debugModeToFileEnabled }) { f, v -> f.messages.debugModeToFileEnabled = v },
|
||||
|
||||
// GUI, all but the language
|
||||
flag("gui dark by default", { it.gui.gUI_darkModeActiveByDefault }) { f, v -> f.gui.gUI_darkModeActiveByDefault = v },
|
||||
flag("gui filter nothing", { it.gui.guiOptions_defaultFilterNothing }) { f, v -> f.gui.guiOptions_defaultFilterNothing = v },
|
||||
flag("gui filter pm to me", { it.gui.guiOptions_defaultFilterPmToMe }) { f, v -> f.gui.guiOptions_defaultFilterPmToMe = v },
|
||||
flag("gui filter pm to other", { it.gui.guiOptions_defaultFilterPmToOther }) { f, v ->
|
||||
f.gui.guiOptions_defaultFilterPmToOther = v
|
||||
},
|
||||
flag("gui filter public", { it.gui.guiOptions_defaultFilterPublicMsgs }) { f, v -> f.gui.guiOptions_defaultFilterPublicMsgs = v },
|
||||
flag("gui fresh call hint", { it.gui.guiOptions_showFreshCallHintInBandColumns }) { f, v ->
|
||||
f.gui.guiOptions_showFreshCallHintInBandColumns = v
|
||||
},
|
||||
flag("gui gross field hint", { it.gui.guiOptions_showGrossFieldWorkedHintInBandColumns }) { f, v ->
|
||||
f.gui.guiOptions_showGrossFieldWorkedHintInBandColumns = v
|
||||
},
|
||||
)
|
||||
|
||||
@TestFactory
|
||||
fun everyDraftedSettingWaitsForApply(): List<DynamicTest> = cases().map { case ->
|
||||
DynamicTest.dynamicTest(case.name) {
|
||||
val f = Fixture(ChatPreferences(fresh().resolve("preferences.xml")))
|
||||
val before = case.stored(f)
|
||||
|
||||
case.change(f)
|
||||
assertEquals(before, storedNow(case, f), "${case.name} took effect before Apply")
|
||||
|
||||
f.draft.apply()
|
||||
assertNotEquals(before, storedNow(case, f), "${case.name} did not take effect with Apply")
|
||||
}
|
||||
}
|
||||
|
||||
@TestFactory
|
||||
fun everyDraftedSettingIsLeftAloneByDiscard(): List<DynamicTest> = cases().map { case ->
|
||||
DynamicTest.dynamicTest(case.name) {
|
||||
val f = Fixture(ChatPreferences(fresh().resolve("preferences.xml")))
|
||||
val before = case.stored(f)
|
||||
|
||||
case.change(f)
|
||||
f.draft.discard()
|
||||
|
||||
assertEquals(before, storedNow(case, f), "${case.name} changed although discarded")
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* The stored value, read past the draft: a flag case reads through the tab, which shows
|
||||
* the draft, so it reads from tab states over the same preferences with a draft of their own.
|
||||
*/
|
||||
private fun storedNow(case: Case, f: Fixture): Any? = case.stored(Fixture(f.prefs, f.monitored))
|
||||
|
||||
private fun fresh(): Path = Files.createTempDirectory(directory, "case")
|
||||
}
|
||||
+23
-6
@@ -1,6 +1,8 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import kst4contest.model.ChatPreferences
|
||||
import kst4contest.view.compose.ImmediateDraft
|
||||
import kst4contest.view.compose.SettingsDraft
|
||||
import kst4contest.view.i18n.LanguageStore
|
||||
import kst4contest.view.i18n.LanguageStoreFactory
|
||||
import kst4contest.view.i18n.SYSTEM_LANGUAGE
|
||||
@@ -36,12 +38,14 @@ class GuiOptionsTabStateTest(@TempDir private val tempDir: Path) {
|
||||
* open windows. A store wired to anything else would pass a weaker test.
|
||||
*/
|
||||
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||
val state = GuiOptionsTabState(prefs, LanguageStoreFactory.create(prefs, Locale.UK))
|
||||
val draft = SettingsDraft()
|
||||
val state = GuiOptionsTabState(prefs, draft, LanguageStoreFactory.create(prefs, Locale.UK))
|
||||
|
||||
state.language = "de"
|
||||
|
||||
assertEquals("de", state.language)
|
||||
assertEquals("de", prefs.guiOptions_language)
|
||||
assertEquals("de", prefs.guiOptions_language, "the language switches at once, without Apply")
|
||||
assertFalse(draft.hasPendingChanges)
|
||||
}
|
||||
|
||||
|
||||
@@ -50,16 +54,29 @@ class GuiOptionsTabStateTest(@TempDir private val tempDir: Path) {
|
||||
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||
prefs.setGUI_darkModeActiveByDefault(true)
|
||||
|
||||
assertTrue(GuiOptionsTabState(prefs, languageStore()).gUI_darkModeActiveByDefault)
|
||||
assertTrue(GuiOptionsTabState(prefs, SettingsDraft(), languageStore()).gUI_darkModeActiveByDefault)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `writing reaches the preferences at once`() {
|
||||
fun `an edit waits for apply, and discarding drops it`() {
|
||||
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||
val state = GuiOptionsTabState(prefs, languageStore())
|
||||
val draft = SettingsDraft()
|
||||
val state = GuiOptionsTabState(prefs, draft, languageStore())
|
||||
|
||||
state.gUI_darkModeActiveByDefault = !prefs.isGUI_darkModeActiveByDefault()
|
||||
state.guiOptions_defaultFilterPublicMsgs = true
|
||||
state.guiOptions_showFreshCallHintInBandColumns = true
|
||||
|
||||
assertFalse(prefs.isGuiOptions_defaultFilterPublicMsgs(), "an edit took effect before Apply")
|
||||
assertTrue(state.guiOptions_defaultFilterPublicMsgs, "the tab shows the edit")
|
||||
|
||||
val darkBefore = prefs.isGUI_darkModeActiveByDefault()
|
||||
draft.discard()
|
||||
assertEquals(darkBefore, prefs.isGUI_darkModeActiveByDefault())
|
||||
|
||||
state.guiOptions_defaultFilterPublicMsgs = true
|
||||
state.guiOptions_showFreshCallHintInBandColumns = true
|
||||
draft.apply()
|
||||
|
||||
assertTrue(prefs.isGuiOptions_defaultFilterPublicMsgs())
|
||||
assertTrue(prefs.isGuiOptions_showFreshCallHintInBandColumns())
|
||||
@@ -71,7 +88,7 @@ class GuiOptionsTabStateTest(@TempDir private val tempDir: Path) {
|
||||
// one. Turning them into a radio group would take a combination away that
|
||||
// an operator may have configured.
|
||||
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||
val state = GuiOptionsTabState(prefs, languageStore())
|
||||
val state = GuiOptionsTabState(prefs, ImmediateDraft(), languageStore())
|
||||
|
||||
state.guiOptions_defaultFilterPublicMsgs = true
|
||||
state.guiOptions_defaultFilterPmToMe = true
|
||||
|
||||
@@ -13,6 +13,7 @@ import androidx.compose.ui.test.pressKey
|
||||
import androidx.compose.ui.test.runComposeUiTest
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.ChatPreferences
|
||||
import kst4contest.view.compose.ImmediateDraft
|
||||
import kst4contest.observe.SimpleRoster
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
@@ -54,7 +55,7 @@ class ListTabsScreenTest {
|
||||
}
|
||||
var shortcutRefreshes = 0
|
||||
var snippetRefreshes = 0
|
||||
val state = ShortcutsTabState(prefs, { shortcutRefreshes++ }, { snippetRefreshes++ })
|
||||
val state = ShortcutsTabState(prefs, ImmediateDraft(), { shortcutRefreshes++ }, { snippetRefreshes++ })
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import kst4contest.model.ChatPreferences
|
||||
import kst4contest.view.compose.SettingsDraft
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
@@ -10,17 +11,17 @@ import java.nio.file.Path
|
||||
|
||||
class LogSynchTabStateTest(@TempDir private val tempDir: Path) {
|
||||
|
||||
private inner class Session(detected: String? = null) {
|
||||
private inner class Session {
|
||||
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||
var restarts = 0
|
||||
var stops = 0
|
||||
val detectedBroadcast = detected
|
||||
val draft = SettingsDraft()
|
||||
|
||||
fun state() = LogSynchTabState(
|
||||
prefs,
|
||||
draft,
|
||||
restartWintestListener = { restarts++ },
|
||||
stopWintestListener = { stops++ },
|
||||
detectWintestBroadcastAddress = { detectedBroadcast },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -44,23 +45,44 @@ class LogSynchTabStateTest(@TempDir private val tempDir: Path) {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `writing reaches the preferences at once, without a confirmation step`() {
|
||||
// The JavaFX window had no value-collecting confirmation: each control wrote
|
||||
// straight into ChatPreferences, and "Save settings" only persisted them.
|
||||
fun `an edit waits for apply, the chosen log file does not`() {
|
||||
val s = Session()
|
||||
s.prefs.setLogsynch_ucxUDPWkdCallListenerPort(12060)
|
||||
s.prefs.setLogsynch_fileBasedWkdCallInterpreterEnabled(false)
|
||||
val state = s.state()
|
||||
|
||||
state.logsynch_fileBasedWkdCallInterpreterEnabled = true
|
||||
state.logsynch_fileBasedWkdCallInterpreterFileNameReadOnly = "/home/op/log.adi"
|
||||
state.logsynch_ucxUDPWkdCallListenerEnabled = true
|
||||
state.logsynch_ucxUDPWkdCallListenerPort = 12060
|
||||
state.logsynch_wintestNetworkPort = 9871
|
||||
state.logsynch_ucxUDPWkdCallListenerPort = 12061
|
||||
state.logsynch_wintestNetworkPort = 9879
|
||||
|
||||
// The file chooser is an action: the chosen path is written when chosen.
|
||||
assertEquals("/home/op/log.adi", s.prefs.getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly())
|
||||
assertFalse(s.prefs.isLogsynch_fileBasedWkdCallInterpreterEnabled(), "an edit took effect before Apply")
|
||||
assertEquals(12060, s.prefs.getLogsynch_ucxUDPWkdCallListenerPort())
|
||||
assertEquals(12061, state.logsynch_ucxUDPWkdCallListenerPort, "the tab shows the edit")
|
||||
|
||||
s.draft.apply()
|
||||
|
||||
assertTrue(s.prefs.isLogsynch_fileBasedWkdCallInterpreterEnabled())
|
||||
assertEquals("/home/op/log.adi", s.prefs.getLogsynch_fileBasedWkdCallInterpreterFileNameReadOnly())
|
||||
assertTrue(s.prefs.isLogsynch_ucxUDPWkdCallListenerEnabled())
|
||||
assertEquals(12060, s.prefs.getLogsynch_ucxUDPWkdCallListenerPort())
|
||||
assertEquals(9871, s.prefs.getLogsynch_wintestNetworkPort())
|
||||
assertEquals(12061, s.prefs.getLogsynch_ucxUDPWkdCallListenerPort())
|
||||
assertEquals(9879, s.prefs.getLogsynch_wintestNetworkPort())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a discarded edit changes nothing and touches no listener`() {
|
||||
val s = Session()
|
||||
val state = s.state()
|
||||
val enabled = s.prefs.isLogsynch_wintestNetworkListenerEnabled()
|
||||
|
||||
state.logsynch_wintestNetworkListenerEnabled = !enabled
|
||||
state.logsynch_wintestNetworkPort = 9879
|
||||
s.draft.discard()
|
||||
|
||||
assertEquals(enabled, s.prefs.isLogsynch_wintestNetworkListenerEnabled())
|
||||
assertEquals(0, s.restarts + s.stops)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -68,91 +90,84 @@ class LogSynchTabStateTest(@TempDir private val tempDir: Path) {
|
||||
// The JavaFX fields committed getText().trim(); a stray space in a station
|
||||
// name or a broadcast address is a protocol value, not decoration.
|
||||
val s = Session()
|
||||
s.prefs.setLogsynch_wintestNetworkStationNameOfKST("KST")
|
||||
val state = s.state()
|
||||
|
||||
state.logsynch_wintestNetworkStationNameOfKST = " KST "
|
||||
state.logsynch_wintestNetworkBroadcastAddress = " 192.168.1.255 "
|
||||
state.logsynch_wintestNetworkStationNameOfKST = "KST "
|
||||
assertFalse(s.draft.hasPendingChanges, "a trailing space is no change")
|
||||
|
||||
assertEquals("KST", s.prefs.getLogsynch_wintestNetworkStationNameOfKST())
|
||||
state.logsynch_wintestNetworkStationNameOfKST = " KST2 "
|
||||
state.logsynch_wintestNetworkBroadcastAddress = " 192.168.1.255 "
|
||||
s.draft.apply()
|
||||
|
||||
assertEquals("KST2", s.prefs.getLogsynch_wintestNetworkStationNameOfKST())
|
||||
assertEquals("192.168.1.255", s.prefs.getLogsynch_wintestNetworkBroadcastAddress())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enabling the Win-Test listener starts it and disabling stops it`() {
|
||||
fun `applying the Win-Test switch starts or stops the listener`() {
|
||||
val s = Session()
|
||||
s.prefs.setLogsynch_wintestNetworkListenerEnabled(false)
|
||||
val state = s.state()
|
||||
|
||||
state.logsynch_wintestNetworkListenerEnabled = true
|
||||
assertEquals(0, s.restarts, "the listener followed the box before Apply")
|
||||
s.draft.apply()
|
||||
|
||||
assertTrue(s.prefs.isLogsynch_wintestNetworkListenerEnabled())
|
||||
assertEquals(1, s.restarts, "the running listener must follow the setting")
|
||||
|
||||
state.logsynch_wintestNetworkListenerEnabled = false
|
||||
s.draft.apply()
|
||||
|
||||
assertFalse(s.prefs.isLogsynch_wintestNetworkListenerEnabled())
|
||||
assertEquals(1, s.stops)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a committed port change rebinds the listener only while it is enabled`() {
|
||||
// Deliberately not on every keystroke: the port write goes through at once,
|
||||
// but rebinding a UDP socket is bound to the commit, as the JavaFX field's
|
||||
// focus-lost handler did.
|
||||
fun `an applied port change rebinds the listener only while it is enabled`() {
|
||||
val s = Session()
|
||||
val state = s.state()
|
||||
|
||||
/*
|
||||
* Switched off explicitly: ChatPreferences ships
|
||||
* logsynch_wintestNetworkListenerEnabled = true (ChatPreferences.java:254), so
|
||||
* a fresh configuration already listens.
|
||||
*/
|
||||
state.logsynch_wintestNetworkListenerEnabled = false
|
||||
val restartsAfterDisable = s.restarts
|
||||
s.prefs.setLogsynch_wintestNetworkListenerEnabled(false)
|
||||
val state = s.state()
|
||||
|
||||
state.logsynch_wintestNetworkPort = 9872
|
||||
assertEquals(restartsAfterDisable, s.restarts, "a keystroke must not rebind the socket")
|
||||
s.draft.apply()
|
||||
assertEquals(0, s.restarts, "a disabled listener has nothing to rebind")
|
||||
|
||||
state.commitWintestNetworkPort()
|
||||
assertEquals(restartsAfterDisable, s.restarts, "a disabled listener has nothing to rebind")
|
||||
|
||||
state.logsynch_wintestNetworkListenerEnabled = true
|
||||
val restartsAfterEnable = s.restarts
|
||||
s.prefs.setLogsynch_wintestNetworkListenerEnabled(true)
|
||||
state.logsynch_wintestNetworkPort = 9873
|
||||
state.commitWintestNetworkPort()
|
||||
assertEquals(0, s.restarts, "a keystroke must not rebind the socket")
|
||||
s.draft.apply()
|
||||
|
||||
assertEquals(9873, s.prefs.getLogsynch_wintestNetworkPort())
|
||||
assertEquals(restartsAfterEnable + 1, s.restarts)
|
||||
assertEquals(1, s.restarts)
|
||||
}
|
||||
|
||||
/*
|
||||
* Switch and port in one apply: one restart, on the new port. Two restarts in a row can
|
||||
* fail on the port the first has not released yet.
|
||||
*/
|
||||
@Test
|
||||
fun `a still-default broadcast address is replaced by the detected one`() {
|
||||
val s = Session(detected = "192.168.178.255")
|
||||
s.prefs.setLogsynch_wintestNetworkBroadcastAddress("255.255.255.255")
|
||||
fun `switch and port applied together rebind once, on the new port`() {
|
||||
val s = Session()
|
||||
s.prefs.setLogsynch_wintestNetworkListenerEnabled(false)
|
||||
var portAtRestart = -1
|
||||
val state = LogSynchTabState(
|
||||
s.prefs, s.draft,
|
||||
restartWintestListener = { s.restarts++; portAtRestart = s.prefs.getLogsynch_wintestNetworkPort() },
|
||||
stopWintestListener = { s.stops++ },
|
||||
)
|
||||
|
||||
s.state()
|
||||
state.logsynch_wintestNetworkListenerEnabled = true
|
||||
state.logsynch_wintestNetworkPort = 9874
|
||||
s.draft.apply()
|
||||
|
||||
assertEquals("192.168.178.255", s.prefs.getLogsynch_wintestNetworkBroadcastAddress(),
|
||||
"the JavaFX window auto-detected the subnet broadcast while the stored "
|
||||
+ "value was still the catch-all default")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a broadcast address the operator chose is never overwritten`() {
|
||||
val s = Session(detected = "192.168.178.255")
|
||||
s.prefs.setLogsynch_wintestNetworkBroadcastAddress("10.0.0.255")
|
||||
|
||||
s.state()
|
||||
|
||||
assertEquals("10.0.0.255", s.prefs.getLogsynch_wintestNetworkBroadcastAddress())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed detection leaves the default in place`() {
|
||||
val s = Session(detected = null)
|
||||
s.prefs.setLogsynch_wintestNetworkBroadcastAddress("255.255.255.255")
|
||||
|
||||
s.state()
|
||||
|
||||
assertEquals("255.255.255.255", s.prefs.getLogsynch_wintestNetworkBroadcastAddress())
|
||||
assertEquals(1, s.restarts)
|
||||
assertEquals(9874, portAtRestart)
|
||||
}
|
||||
}
|
||||
+41
-5
@@ -1,6 +1,8 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import kst4contest.model.ChatPreferences
|
||||
import kst4contest.view.compose.ImmediateDraft
|
||||
import kst4contest.view.compose.SettingsDraft
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
@@ -17,19 +19,53 @@ class MessageHandlingTabStateTest(@TempDir private val tempDir: Path) {
|
||||
prefs.setMessageHandling_autoAnswerToQRGRequestEnabled(true)
|
||||
prefs.setMessageHandling_autoAnswerTextMainCat("qrv 144.300")
|
||||
|
||||
val state = MessageHandlingTabState(prefs)
|
||||
val state = MessageHandlingTabState(prefs, ImmediateDraft())
|
||||
|
||||
assertTrue(state.autoAnswerEnabled)
|
||||
assertTrue(state.autoAnswerToQRGRequestEnabled)
|
||||
assertEquals("qrv 144.300", state.autoAnswerText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an edit waits for apply, and the recording follows only then`() {
|
||||
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||
val draft = SettingsDraft()
|
||||
val recording = mutableListOf<Boolean>()
|
||||
val state = MessageHandlingTabState(prefs, draft) { recording += it }
|
||||
|
||||
state.autoAnswerText = "qrv 432.200"
|
||||
state.debugModeToFileEnabled = !prefs.isMessageHandling_debugModeToFileEnabled()
|
||||
val wanted = state.debugModeToFileEnabled
|
||||
|
||||
assertEquals("qrv 432.200", state.autoAnswerText, "the tab shows the edit")
|
||||
assertTrue(recording.isEmpty(), "the recording followed before Apply")
|
||||
|
||||
draft.apply()
|
||||
|
||||
assertEquals("qrv 432.200", prefs.getMessageHandling_autoAnswerTextSecondCat())
|
||||
assertEquals(listOf(wanted), recording)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a discarded edit changes nothing`() {
|
||||
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||
prefs.setMessageHandling_autoAnswerEnabled(false)
|
||||
val draft = SettingsDraft()
|
||||
val state = MessageHandlingTabState(prefs, draft)
|
||||
|
||||
state.autoAnswerEnabled = true
|
||||
draft.discard()
|
||||
|
||||
assertFalse(prefs.isMessageHandling_autoAnswerEnabled())
|
||||
assertFalse(state.autoAnswerEnabled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one checkbox drives both category flags`() {
|
||||
// The JavaFX window offered a single checkbox and wrote both preferences from
|
||||
// it; keeping only the main one would leave the second chat on its old value.
|
||||
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||
val state = MessageHandlingTabState(prefs)
|
||||
val state = MessageHandlingTabState(prefs, ImmediateDraft())
|
||||
|
||||
state.autoAnswerEnabled = true
|
||||
|
||||
@@ -45,7 +81,7 @@ class MessageHandlingTabStateTest(@TempDir private val tempDir: Path) {
|
||||
@Test
|
||||
fun `one text field drives both category texts`() {
|
||||
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||
val state = MessageHandlingTabState(prefs)
|
||||
val state = MessageHandlingTabState(prefs, ImmediateDraft())
|
||||
|
||||
state.autoAnswerText = "qrv 432.200"
|
||||
|
||||
@@ -54,9 +90,9 @@ class MessageHandlingTabStateTest(@TempDir private val tempDir: Path) {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the QRG reply flag writes through on its own`() {
|
||||
fun `the QRG reply flag is stored on its own`() {
|
||||
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||
val state = MessageHandlingTabState(prefs)
|
||||
val state = MessageHandlingTabState(prefs, ImmediateDraft())
|
||||
|
||||
state.autoAnswerToQRGRequestEnabled = true
|
||||
|
||||
|
||||
+34
-17
@@ -15,6 +15,8 @@ import androidx.compose.ui.test.runComposeUiTest
|
||||
import kst4contest.model.Band
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.ChatPreferences
|
||||
import kst4contest.view.compose.ImmediateDraft
|
||||
import kst4contest.view.compose.SettingsDraft
|
||||
import kst4contest.observe.SimpleRoster
|
||||
import kst4contest.view.i18n.LanguageStore
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
@@ -60,7 +62,7 @@ class MessagingTabsScreenTest {
|
||||
isMessageHandling_debugModeToFileEnabled = false
|
||||
}
|
||||
val recording = mutableListOf<Boolean>()
|
||||
val state = MessageHandlingTabState(prefs) { recording += it }
|
||||
val state = MessageHandlingTabState(prefs, ImmediateDraft()) { recording += it }
|
||||
showTab { MessageHandlingTab(state) }
|
||||
|
||||
onNodeWithText("Automatic answer to private messages").assertExists()
|
||||
@@ -87,7 +89,7 @@ class MessagingTabsScreenTest {
|
||||
val prefs = preferences().apply { isGUI_darkModeActiveByDefault = false }
|
||||
var storedLanguage = "en"
|
||||
val languages = LanguageStore({ storedLanguage }, { storedLanguage = it }, Locale.UK)
|
||||
val state = GuiOptionsTabState(prefs, languages)
|
||||
val state = GuiOptionsTabState(prefs, ImmediateDraft(), languages)
|
||||
showTab { GuiOptionsTab(state) }
|
||||
|
||||
onNodeWithText("Startup design").assertExists()
|
||||
@@ -117,11 +119,21 @@ class MessagingTabsScreenTest {
|
||||
|
||||
// ------------------------------------------------------------------ Beacon
|
||||
|
||||
private class Beacon(val prefs: ChatPreferences, val refuse: Boolean = false) {
|
||||
/*
|
||||
* A draft that applies at once by default, so the tests below keep pinning the field's
|
||||
* commit rules (#128) against the stored value; the drafted behaviour is pinned by the
|
||||
* tests that pass a real draft.
|
||||
*/
|
||||
private class Beacon(
|
||||
val prefs: ChatPreferences,
|
||||
val refuse: Boolean = false,
|
||||
val draft: SettingsDraft = ImmediateDraft(),
|
||||
) {
|
||||
var restarts = 0
|
||||
val refusals = mutableListOf<String>()
|
||||
val state = BeaconTabState(
|
||||
prefs,
|
||||
draft,
|
||||
validateBeaconTemplate = { if (refuse) throw IllegalArgumentException("no variable %foo%") },
|
||||
restartBeaconTimer = { restarts++ },
|
||||
mainCategoryName = "Microwave",
|
||||
@@ -226,15 +238,15 @@ class MessagingTabsScreenTest {
|
||||
}
|
||||
|
||||
/*
|
||||
* The stored value can change under a field without the field's own key changing: the
|
||||
* field commits 9 over 5, nothing recomposes the tab, then a profile switch puts 5 back
|
||||
* and the tab recomposes with 5 -- the same `stored` it was first drawn with. The field
|
||||
* still shows 9, so leaving it must commit 9 again; judged against what the field itself
|
||||
* last committed, it looked unchanged and the shown value silently differed from the
|
||||
* stored one.
|
||||
* The stored value can change under a field -- a profile switch puts 5 back after the field
|
||||
* committed 9. The field used to keep showing 9 then, because nothing recomposed the tab
|
||||
* after a commit, and #128 made leaving it commit the shown 9 again. Since the edits go
|
||||
* through the settings draft, which is Compose state, a commit recomposes the tab: the field
|
||||
* follows the stored value, shows the 5, and leaving it commits nothing. Either way what the
|
||||
* field shows is what is stored once the operator has left it.
|
||||
*/
|
||||
@Test
|
||||
fun aValueResetBehindTheFieldIsCommittedAgainWhenTheFieldIsLeft() = runComposeUiTest {
|
||||
fun aValueResetBehindTheFieldIsShownAndNotCommittedAgain() = runComposeUiTest {
|
||||
val s = Beacon(preferences())
|
||||
s.prefs.bcn_beaconIntervalInMinutesMainCat = 5
|
||||
val generation = mutableStateOf(0)
|
||||
@@ -251,13 +263,13 @@ class MessagingTabsScreenTest {
|
||||
generation.value++
|
||||
}
|
||||
waitForIdle()
|
||||
fieldBeside("Interval in whole minutes").assertTextEquals("9")
|
||||
fieldBeside("Interval in whole minutes").assertTextEquals("5")
|
||||
|
||||
fieldBeside("Beacon message").performScrollTo().performClick()
|
||||
waitForIdle()
|
||||
|
||||
assertEquals(9, s.prefs.bcn_beaconIntervalInMinutesMainCat, "the shown 9 was not committed again")
|
||||
assertEquals(2, s.restarts)
|
||||
assertEquals(5, s.prefs.bcn_beaconIntervalInMinutesMainCat, "the field committed a value it no longer showed")
|
||||
assertEquals(1, s.restarts)
|
||||
}
|
||||
|
||||
/** Leaving the field commits what was typed, without Enter, and restarts the timer once. */
|
||||
@@ -281,7 +293,7 @@ class MessagingTabsScreenTest {
|
||||
|
||||
// ------------------------------------------------------------------ Notification
|
||||
|
||||
private class Notification(val prefs: ChatPreferences) {
|
||||
private class Notification(val prefs: ChatPreferences, val draft: SettingsDraft = ImmediateDraft()) {
|
||||
init {
|
||||
prefs.isNotify_dxClusterServerEnabled = false
|
||||
}
|
||||
@@ -292,15 +304,19 @@ class MessagingTabsScreenTest {
|
||||
val refusals = mutableListOf<String>()
|
||||
val state = NotificationTabState(
|
||||
prefs,
|
||||
draft,
|
||||
monitored,
|
||||
applyDxClusterServerEnabled = { serverEnabled += it },
|
||||
applyDxClusterServerPort = { ports += it },
|
||||
// What the server is brought to on apply: its switch, and the port it binds.
|
||||
applyDxClusterServer = {
|
||||
serverEnabled += prefs.isNotify_dxClusterServerEnabled
|
||||
ports += prefs.notify_dxclusterServerPort
|
||||
},
|
||||
broadcastTestSpot = { spots += it; "no logger connected" },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun notificationWritesThroughAndStartsTheClusterServer() = runComposeUiTest {
|
||||
fun notificationStoresItsFieldsAndStartsTheClusterServer() = runComposeUiTest {
|
||||
val s = Notification(preferences())
|
||||
showTab { NotificationTab(s.state) { s.refusals += it } }
|
||||
|
||||
@@ -314,6 +330,7 @@ class MessagingTabsScreenTest {
|
||||
toggle("Run the DX Cluster server")
|
||||
assertTrue(s.prefs.isNotify_dxClusterServerEnabled)
|
||||
assertEquals(listOf(true), s.serverEnabled)
|
||||
s.ports.clear()
|
||||
|
||||
typeAndCommit("Spotter callsign", "dn9apw")
|
||||
assertEquals("DN9APW", s.prefs.notify_DXCSrv_SpottersCallSign.get())
|
||||
|
||||
+93
-16
@@ -4,6 +4,8 @@ import kst4contest.model.Band
|
||||
import kst4contest.model.ChatMember
|
||||
import kst4contest.model.ChatPreferences
|
||||
import kst4contest.observe.SimpleRoster
|
||||
import kst4contest.view.compose.ImmediateDraft
|
||||
import kst4contest.view.compose.SettingsDraft
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
@@ -16,25 +18,99 @@ import java.nio.file.Path
|
||||
|
||||
class NotificationTabStateTest(@TempDir private val tempDir: Path) {
|
||||
|
||||
private inner class Session {
|
||||
/*
|
||||
* The rules are pinned with a draft that applies at once; what the draft changes is
|
||||
* pinned by the tests that pass a real one.
|
||||
*/
|
||||
private inner class Session(val draft: SettingsDraft = ImmediateDraft()) {
|
||||
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||
val monitored = SimpleRoster<String>()
|
||||
var serverEnabledApplied: Boolean? = null
|
||||
var portApplied: Int? = null
|
||||
var serverApplies = 0
|
||||
var enabledAtApply: Boolean? = null
|
||||
var portAtApply: Int? = null
|
||||
var spotSent: ChatMember? = null
|
||||
var spotRefusal: String? = null
|
||||
|
||||
fun state() = NotificationTabState(
|
||||
prefs,
|
||||
draft,
|
||||
monitored,
|
||||
applyDxClusterServerEnabled = { serverEnabledApplied = it },
|
||||
applyDxClusterServerPort = { portApplied = it },
|
||||
applyDxClusterServer = {
|
||||
serverApplies++
|
||||
enabledAtApply = prefs.isNotify_dxClusterServerEnabled()
|
||||
portAtApply = prefs.getNotify_dxclusterServerPort()
|
||||
},
|
||||
broadcastTestSpot = { spotSent = it; spotRefusal },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the sound settings read from and write to the preferences at once`() {
|
||||
fun `an edit waits for apply, and discarding drops it`() {
|
||||
val s = Session(SettingsDraft())
|
||||
s.prefs.setNotify_playSimpleSounds(true)
|
||||
val state = s.state()
|
||||
|
||||
state.notify_playSimpleSounds = false
|
||||
state.notify_optionalFrequencyPrefix = Band.B_432
|
||||
assertNull(state.commitSpotterCallSign("DO5AMF"))
|
||||
|
||||
assertTrue(s.prefs.isNotify_playSimpleSounds(), "an edit took effect before Apply")
|
||||
assertFalse(state.notify_playSimpleSounds, "the tab shows the edit")
|
||||
assertEquals("DO5AMF", state.notify_DXCSrv_SpottersCallSign)
|
||||
|
||||
s.draft.discard()
|
||||
assertTrue(s.prefs.isNotify_playSimpleSounds())
|
||||
assertTrue(state.notify_playSimpleSounds)
|
||||
|
||||
state.notify_playSimpleSounds = false
|
||||
s.draft.apply()
|
||||
assertFalse(s.prefs.isNotify_playSimpleSounds())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the server is touched on apply only, and once for switch and port together`() {
|
||||
val s = Session(SettingsDraft())
|
||||
s.prefs.setNotify_dxClusterServerEnabled(false)
|
||||
s.prefs.setNotify_dxclusterServerPort(8000)
|
||||
val state = s.state()
|
||||
|
||||
state.enableDxClusterServer(true)
|
||||
assertNull(state.commitDxClusterServerPort("7300"))
|
||||
assertEquals(0, s.serverApplies, "the server moved before Apply")
|
||||
|
||||
s.draft.apply()
|
||||
|
||||
assertEquals(1, s.serverApplies, "two restarts in a row can fail on the port")
|
||||
assertEquals(true, s.enabledAtApply)
|
||||
assertEquals(7300, s.portAtApply, "the server must come up on the new port")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the monitoring list reaches the roster on apply`() {
|
||||
val s = Session(SettingsDraft())
|
||||
val state = s.state()
|
||||
|
||||
assertNull(state.addMonitoredCallSign("DN9APW"))
|
||||
assertTrue(s.monitored.snapshot().isEmpty(), "the list took effect before Apply")
|
||||
assertTrue(s.draft.hasPendingChanges)
|
||||
|
||||
s.draft.apply()
|
||||
assertEquals(listOf("DN9APW"), s.monitored.snapshot())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the test spot is an action and needs no apply`() {
|
||||
val s = Session(SettingsDraft())
|
||||
val state = s.state()
|
||||
|
||||
assertNull(state.sendTestSpot())
|
||||
|
||||
assertNotNull(s.spotSent)
|
||||
assertFalse(s.draft.hasPendingChanges)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the sound settings read from and write to the preferences`() {
|
||||
val s = Session()
|
||||
s.prefs.setNotify_playSimpleSounds(true)
|
||||
val state = s.state()
|
||||
@@ -51,7 +127,7 @@ class NotificationTabStateTest(@TempDir private val tempDir: Path) {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the band-upgrade hints write through`() {
|
||||
fun `the band-upgrade hints reach the preferences`() {
|
||||
val s = Session()
|
||||
val state = s.state()
|
||||
|
||||
@@ -65,12 +141,13 @@ class NotificationTabStateTest(@TempDir private val tempDir: Path) {
|
||||
@Test
|
||||
fun `enabling the DX cluster server also tells the running session`() {
|
||||
val s = Session()
|
||||
s.prefs.setNotify_dxClusterServerEnabled(false)
|
||||
val state = s.state()
|
||||
|
||||
state.enableDxClusterServer(true)
|
||||
|
||||
assertTrue(s.prefs.isNotify_dxClusterServerEnabled())
|
||||
assertEquals(true, s.serverEnabledApplied,
|
||||
assertEquals(true, s.enabledAtApply,
|
||||
"the JavaFX checkbox started or stopped the server as well, and the "
|
||||
+ "settings coverage test cannot see that call")
|
||||
}
|
||||
@@ -83,7 +160,7 @@ class NotificationTabStateTest(@TempDir private val tempDir: Path) {
|
||||
assertNull(state.commitDxClusterServerPort("7300"))
|
||||
|
||||
assertEquals(7300, s.prefs.getNotify_dxclusterServerPort())
|
||||
assertEquals(7300, s.portApplied)
|
||||
assertEquals(7300, s.portAtApply)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -96,7 +173,7 @@ class NotificationTabStateTest(@TempDir private val tempDir: Path) {
|
||||
|
||||
assertNotNull(refusal, "the JavaFX field alerted and restored the previous port")
|
||||
assertEquals(8000, s.prefs.getNotify_dxclusterServerPort())
|
||||
assertNull(s.portApplied, "an refused port must not restart the server")
|
||||
assertEquals(0, s.serverApplies, "an refused port must not restart the server")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -117,7 +194,7 @@ class NotificationTabStateTest(@TempDir private val tempDir: Path) {
|
||||
|
||||
assertNull(state.commitDxClusterServerPort("8000"))
|
||||
|
||||
assertNull(s.portApplied,
|
||||
assertEquals(0, s.serverApplies,
|
||||
"the JavaFX field only restarted the server when the port actually changed")
|
||||
}
|
||||
|
||||
@@ -155,15 +232,15 @@ class NotificationTabStateTest(@TempDir private val tempDir: Path) {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unsupported stored prefix falls back to 144 and is written back`() {
|
||||
val s = Session()
|
||||
fun `an unsupported stored prefix is shown as 144 without writing anything`() {
|
||||
val s = Session(SettingsDraft())
|
||||
s.prefs.setNotify_optionalFrequencyPrefix("4711")
|
||||
|
||||
val state = s.state()
|
||||
|
||||
assertEquals(Band.B_144, state.notify_optionalFrequencyPrefix)
|
||||
assertEquals("144", s.prefs.getNotify_optionalFrequencyPrefix().get(),
|
||||
"the JavaFX window repaired the stored value the same way")
|
||||
assertEquals("4711", s.prefs.getNotify_optionalFrequencyPrefix().get(),
|
||||
"reading the tab wrote the preferences; repairStoredSettings does that, once")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -184,7 +261,7 @@ class NotificationTabStateTest(@TempDir private val tempDir: Path) {
|
||||
assertEquals(listOf("DN9APW"), state.monitoredCallSigns.texts,
|
||||
"monitoring works on the base call, so every SSID maps to the same entry")
|
||||
assertEquals(listOf("DN9APW"), s.monitored.snapshot(),
|
||||
"the JavaFX button added to the roster at once")
|
||||
"applied, the roster holds the entry")
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,579 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.isDialog
|
||||
import androidx.compose.ui.test.hasAnyAncestor
|
||||
import androidx.compose.ui.test.assertIsNotEnabled
|
||||
import androidx.compose.ui.test.hasSetTextAction
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.performTextReplacement
|
||||
import androidx.compose.ui.test.runComposeUiTest
|
||||
import kst4contest.model.ChatPreferences
|
||||
import kst4contest.view.compose.Form
|
||||
import kst4contest.view.compose.Kst4ContestTheme
|
||||
import kst4contest.view.compose.SettingsQuestion
|
||||
import kst4contest.view.compose.runOnce
|
||||
import kst4contest.view.compose.SettingsContent
|
||||
import kst4contest.view.compose.SettingsDraft
|
||||
import kst4contest.view.compose.SettingsNotice
|
||||
import kst4contest.view.compose.SettingsNotices
|
||||
import kst4contest.view.compose.SettingsTab
|
||||
import kst4contest.view.compose.applyAndSave
|
||||
import kst4contest.view.compose.SettingsWindow
|
||||
import kst4contest.view.compose.applyChecked
|
||||
import kst4contest.view.compose.awaitGone
|
||||
import kst4contest.view.compose.awaitNode
|
||||
import kst4contest.view.compose.inDialog
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
|
||||
/**
|
||||
* The settings window around its tabs: edits wait in the draft, Apply/Close applies them,
|
||||
* Save applies before it writes, and the X asks about what is still waiting.
|
||||
*
|
||||
* Drawn with the Beacon tab because it has all three kinds of control that matter here: a
|
||||
* checkbox, a field that commits on Enter or focus loss, and an effect (the timer restart).
|
||||
* The button bar is a stand-in: the real one needs a ChatController, which opens a database;
|
||||
* it calls the same [applyChecked] and [applyAndSave] this one does.
|
||||
*/
|
||||
@OptIn(ExperimentalTestApi::class)
|
||||
class SettingsApplyScreenTest {
|
||||
|
||||
@TempDir
|
||||
lateinit var directory: Path
|
||||
|
||||
private inner class Window(refuse: Boolean = false, timerFails: Boolean = false) {
|
||||
val prefs = ChatPreferences(directory.resolve("preferences.xml")).apply {
|
||||
bcn_beaconIntervalInMinutesMainCat = 5
|
||||
isBcn_beaconsEnabledMainCat = false
|
||||
}
|
||||
val draft = SettingsDraft()
|
||||
val notices = SettingsNotices()
|
||||
val question = SettingsQuestion()
|
||||
var closed = false
|
||||
var restarts = 0
|
||||
|
||||
/** The title bar's X. */
|
||||
fun x() = question.settleClose(draft, notices)
|
||||
|
||||
/** A profile switch or the exit, as SettingsWindow.settleThen hands it on. */
|
||||
fun switchOrExit(action: () -> Unit) = question.settle(draft, notices, then = action, nothingPending = action)
|
||||
val state = BeaconTabState(
|
||||
prefs,
|
||||
draft,
|
||||
validateBeaconTemplate = { if (refuse) throw IllegalArgumentException("no variable %foo%") },
|
||||
restartBeaconTimer = {
|
||||
restarts++
|
||||
if (timerFails) throw IllegalStateException("timer gone")
|
||||
},
|
||||
mainCategoryName = "Microwave",
|
||||
secondCategoryName = "144/432 MHz",
|
||||
)
|
||||
val station = StationTabState(prefs, draft, { }, { })
|
||||
val airscout = AirscoutTabState(prefs, draft)
|
||||
val tabs = listOf(
|
||||
SettingsTab("Beacon") {
|
||||
androidx.compose.foundation.layout.Column {
|
||||
BeaconTab(state, notices::problem)
|
||||
StationTab(station)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun androidx.compose.ui.test.ComposeUiTest.show(w: Window, tabs: List<SettingsTab> = w.tabs) {
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||
SettingsContent(
|
||||
tabs = tabs,
|
||||
notices = w.notices,
|
||||
buttons = { close ->
|
||||
Form.button(APPLY_AND_CLOSE) { if (applyChecked(w.draft, w.notices)) close() }
|
||||
},
|
||||
close = { w.closed = true },
|
||||
draft = w.draft,
|
||||
question = w.question,
|
||||
)
|
||||
}
|
||||
}
|
||||
waitForIdle()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anEditTakesEffectOnlyWithApplyAndClose() = runComposeUiTest {
|
||||
val w = Window()
|
||||
show(w)
|
||||
|
||||
toggle("Send the beacon in this category")
|
||||
typeAndCommit("Interval in whole minutes", "9")
|
||||
|
||||
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat, "the box took effect before Apply")
|
||||
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat)
|
||||
assertEquals(0, w.restarts, "the timer restarted before Apply")
|
||||
|
||||
onNodeWithText(APPLY_AND_CLOSE).performClick()
|
||||
waitForIdle()
|
||||
|
||||
assertTrue(w.prefs.isBcn_beaconsEnabledMainCat)
|
||||
assertEquals(9, w.prefs.bcn_beaconIntervalInMinutesMainCat)
|
||||
assertEquals(1, w.restarts)
|
||||
assertTrue(w.closed)
|
||||
}
|
||||
|
||||
/*
|
||||
* A click on a button leaves the focus in the text field, so the field has not committed
|
||||
* when the button runs. Apply commits it first; otherwise the entry would reach the draft
|
||||
* only when the closing window removed the field -- after the apply, into nothing.
|
||||
*/
|
||||
@Test
|
||||
fun applyAndCloseTakesTheEntryStillInTheFocusedField() = runComposeUiTest {
|
||||
val w = Window()
|
||||
show(w)
|
||||
|
||||
val field = fieldBeside("Interval in whole minutes")
|
||||
field.performScrollTo().performClick()
|
||||
waitForIdle()
|
||||
field.performTextReplacement("9")
|
||||
waitForIdle()
|
||||
|
||||
onNodeWithText(APPLY_AND_CLOSE).performClick()
|
||||
waitForIdle()
|
||||
|
||||
assertEquals(9, w.prefs.bcn_beaconIntervalInMinutesMainCat, "the typed entry was lost")
|
||||
assertEquals(1, w.restarts)
|
||||
assertTrue(w.closed)
|
||||
}
|
||||
|
||||
/* A refused entry stops the button, so the reason is not closed away with the window. */
|
||||
@Test
|
||||
fun aRefusedEntryKeepsTheWindowOpenAndShowsWhy() = runComposeUiTest {
|
||||
val w = Window(refuse = true)
|
||||
show(w)
|
||||
|
||||
val field = fieldBeside("Beacon message")
|
||||
field.performScrollTo().performClick()
|
||||
waitForIdle()
|
||||
field.performTextReplacement("cq %foo%")
|
||||
waitForIdle()
|
||||
|
||||
onNodeWithText(APPLY_AND_CLOSE).performClick()
|
||||
waitForIdle()
|
||||
|
||||
assertFalse(w.closed, "the window closed over a refused entry")
|
||||
assertTrue(w.notices.current is SettingsNotice.Problem)
|
||||
}
|
||||
|
||||
/*
|
||||
* A refusal stops the apply before anything goes into effect: the valid edit beside the
|
||||
* refused one is still pending, so the operator can still discard everything.
|
||||
*/
|
||||
@Test
|
||||
fun aRefusalAppliesNothingSoDiscardIsStillPossible() = runComposeUiTest {
|
||||
val w = Window()
|
||||
show(w)
|
||||
toggle("Send the beacon in this category")
|
||||
type("Server port", "23x")
|
||||
|
||||
onNodeWithText(APPLY_AND_CLOSE).performClick()
|
||||
waitForIdle()
|
||||
|
||||
assertFalse(w.closed)
|
||||
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat, "the valid edit went into effect despite the refusal")
|
||||
assertTrue(w.draft.hasPendingChanges)
|
||||
runOnIdle { w.notices.dismiss() }
|
||||
|
||||
runOnIdle { w.draft.discard() }
|
||||
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat)
|
||||
}
|
||||
|
||||
/*
|
||||
* A number field showing text that does not parse holds the last valid value in the draft.
|
||||
* Applying that value would put something into effect the operator no longer sees.
|
||||
*/
|
||||
@Test
|
||||
fun anInvalidNumberRefusesTheApplyAndNamesTheField() = runComposeUiTest {
|
||||
val w = Window()
|
||||
val storedPort = w.prefs.stn_on4kstServersPort
|
||||
show(w)
|
||||
|
||||
type("Server port", "23005")
|
||||
type("Server port", "23x")
|
||||
onNodeWithText(APPLY_AND_CLOSE).performClick()
|
||||
waitForIdle()
|
||||
|
||||
assertFalse(w.closed, "the window closed with the old port applied")
|
||||
assertEquals(storedPort, w.prefs.stn_on4kstServersPort)
|
||||
val notice = w.notices.current
|
||||
assertTrue(notice is SettingsNotice.Problem && notice.message.contains("Server port") &&
|
||||
notice.message.contains("23x"), "the notice does not name the field: $notice")
|
||||
}
|
||||
|
||||
/* The same for the guarded fields of the external interfaces. */
|
||||
@Test
|
||||
fun anInvalidGuardedEntryRefusesTheApplyAndNamesTheField() = runComposeUiTest {
|
||||
val w = Window()
|
||||
show(w, listOf(SettingsTab("Airscout") { AirscoutTab(w.airscout) }))
|
||||
|
||||
type(AIRSCOUT_PORT, "0")
|
||||
onNodeWithText(APPLY_AND_CLOSE).performClick()
|
||||
waitForIdle()
|
||||
|
||||
assertFalse(w.closed)
|
||||
val notice = w.notices.current
|
||||
assertTrue(notice is SettingsNotice.Problem && notice.message.contains(AIRSCOUT_PORT),
|
||||
"the notice does not name the field: $notice")
|
||||
}
|
||||
|
||||
/* A failing effect is reported, the window stays open, and the other changes are in. */
|
||||
@Test
|
||||
fun aFailingEffectIsReportedAndTheRestIsApplied() = runComposeUiTest {
|
||||
val w = Window(timerFails = true)
|
||||
show(w)
|
||||
toggle("Send the beacon in this category")
|
||||
typeAndCommit("Interval in whole minutes", "9")
|
||||
|
||||
onNodeWithText(APPLY_AND_CLOSE).performClick()
|
||||
waitForIdle()
|
||||
|
||||
assertFalse(w.closed, "the failure was closed away")
|
||||
assertTrue(w.prefs.isBcn_beaconsEnabledMainCat)
|
||||
assertEquals(9, w.prefs.bcn_beaconIntervalInMinutesMainCat)
|
||||
val notice = w.notices.current
|
||||
assertTrue(notice is SettingsNotice.Problem && notice.message.contains("timer gone"), "$notice")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theXClosesAtOnceWhenNothingIsPending() = runComposeUiTest {
|
||||
val w = Window()
|
||||
show(w)
|
||||
|
||||
runOnIdle { w.x() }
|
||||
waitForIdle()
|
||||
|
||||
assertTrue(w.closed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theXAsksAboutPendingEditsAndApplyAppliesThem() = runComposeUiTest {
|
||||
val w = Window()
|
||||
show(w)
|
||||
toggle("Send the beacon in this category")
|
||||
|
||||
runOnIdle { w.x() }
|
||||
awaitNode(inDialog(PENDING_QUESTION))
|
||||
assertFalse(w.closed, "the window closed without asking")
|
||||
|
||||
awaitNode(inDialog("Apply")).performClick()
|
||||
awaitGone(inDialog(PENDING_QUESTION))
|
||||
|
||||
assertTrue(w.prefs.isBcn_beaconsEnabledMainCat)
|
||||
assertTrue(w.closed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun discardingOnTheXChangesNothing() = runComposeUiTest {
|
||||
val w = Window()
|
||||
show(w)
|
||||
toggle("Send the beacon in this category")
|
||||
typeAndCommit("Interval in whole minutes", "9")
|
||||
|
||||
runOnIdle { w.x() }
|
||||
awaitNode(inDialog("Discard")).performClick()
|
||||
awaitGone(inDialog(PENDING_QUESTION))
|
||||
|
||||
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat)
|
||||
assertEquals(5, w.prefs.bcn_beaconIntervalInMinutesMainCat)
|
||||
assertEquals(0, w.restarts)
|
||||
assertTrue(w.closed)
|
||||
assertFalse(w.draft.hasPendingChanges)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun goingBackKeepsTheWindowAndTheEdits() = runComposeUiTest {
|
||||
val w = Window()
|
||||
show(w)
|
||||
toggle("Send the beacon in this category")
|
||||
|
||||
runOnIdle { w.x() }
|
||||
awaitNode(inDialog("Back to the settings")).performClick()
|
||||
awaitGone(inDialog(PENDING_QUESTION))
|
||||
|
||||
assertFalse(w.closed)
|
||||
assertTrue(w.draft.hasPendingChanges)
|
||||
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat)
|
||||
checkBeside("Send the beacon in this category").assertExists()
|
||||
}
|
||||
|
||||
/* An entry typed just before the X counts as pending, although no Enter committed it. */
|
||||
@Test
|
||||
fun theXAsksAboutAnEntryStillInTheFocusedField() = runComposeUiTest {
|
||||
val w = Window()
|
||||
show(w)
|
||||
|
||||
val field = fieldBeside("Interval in whole minutes")
|
||||
field.performScrollTo().performClick()
|
||||
waitForIdle()
|
||||
field.performTextReplacement("9")
|
||||
waitForIdle()
|
||||
|
||||
runOnIdle { w.x() }
|
||||
awaitNode(inDialog(PENDING_QUESTION))
|
||||
|
||||
assertFalse(w.closed)
|
||||
}
|
||||
|
||||
/* A list row still being edited counts too: the lists commit a row on Enter or focus loss. */
|
||||
@Test
|
||||
fun theXAsksAboutARowStillBeingEdited() = runComposeUiTest {
|
||||
val prefs = ChatPreferences(directory.resolve("preferences.xml")).apply {
|
||||
lst_txtShortCutBtnList.setAll(listOf("MYQRG", "pse sked"))
|
||||
}
|
||||
val draft = SettingsDraft()
|
||||
val question = SettingsQuestion()
|
||||
val notices = SettingsNotices()
|
||||
var closed = false
|
||||
val state = ShortcutsTabState(prefs, draft, { }, { })
|
||||
setContent {
|
||||
Kst4ContestTheme(darkMode = false, baseFontSizeSp = 12f) {
|
||||
SettingsContent(
|
||||
tabs = listOf(SettingsTab("Shortcuts") { ShortcutsTab(state) { } }),
|
||||
notices = notices,
|
||||
buttons = { },
|
||||
close = { closed = true },
|
||||
draft = draft,
|
||||
question = question,
|
||||
)
|
||||
}
|
||||
}
|
||||
waitForIdle()
|
||||
|
||||
val row = onNode(hasSetTextAction() and hasText("pse sked"))
|
||||
row.performScrollTo().performClick()
|
||||
waitForIdle()
|
||||
row.performTextReplacement("pse sked 432")
|
||||
waitForIdle()
|
||||
|
||||
runOnIdle { question.settleClose(draft, notices) }
|
||||
awaitNode(inDialog("Apply")).performClick()
|
||||
awaitGone(inDialog(PENDING_QUESTION))
|
||||
|
||||
assertEquals(listOf("MYQRG", "pse sked 432"), prefs.lst_txtShortCutBtnList.snapshot())
|
||||
assertTrue(closed)
|
||||
}
|
||||
|
||||
/*
|
||||
* A profile switch or the exit asks the same question. Apply applies the edits to the
|
||||
* profile still in force and goes on; the window itself is left to the teardown.
|
||||
*/
|
||||
@Test
|
||||
fun aSwitchOrExitAsksAndApplyGoesOnWithTheEditsApplied() = runComposeUiTest {
|
||||
val w = Window()
|
||||
var wentOn = false
|
||||
show(w)
|
||||
toggle("Send the beacon in this category")
|
||||
|
||||
runOnIdle { w.switchOrExit { wentOn = true } }
|
||||
awaitNode(inDialog("Apply")).performClick()
|
||||
awaitGone(inDialog(PENDING_QUESTION))
|
||||
|
||||
assertTrue(w.prefs.isBcn_beaconsEnabledMainCat)
|
||||
assertTrue(wentOn)
|
||||
assertFalse(w.closed, "the window closed itself instead of leaving that to the teardown")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun goingBackCancelsTheSwitchOrExit() = runComposeUiTest {
|
||||
val w = Window()
|
||||
var wentOn = false
|
||||
show(w)
|
||||
toggle("Send the beacon in this category")
|
||||
|
||||
runOnIdle { w.switchOrExit { wentOn = true } }
|
||||
awaitNode(inDialog("Back to the settings")).performClick()
|
||||
awaitGone(inDialog(PENDING_QUESTION))
|
||||
|
||||
assertFalse(wentOn, "the switch or exit went ahead after Back")
|
||||
assertTrue(w.draft.hasPendingChanges)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun discardingGoesOnWithNothingApplied() = runComposeUiTest {
|
||||
val w = Window()
|
||||
var wentOn = false
|
||||
show(w)
|
||||
toggle("Send the beacon in this category")
|
||||
|
||||
runOnIdle { w.switchOrExit { wentOn = true } }
|
||||
awaitNode(inDialog("Discard")).performClick()
|
||||
awaitGone(inDialog(PENDING_QUESTION))
|
||||
|
||||
assertTrue(wentOn)
|
||||
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aRefusalAtTheQuestionStopsTheSwitchOrExit() = runComposeUiTest {
|
||||
val w = Window()
|
||||
var wentOn = false
|
||||
show(w)
|
||||
toggle("Send the beacon in this category")
|
||||
|
||||
runOnIdle { w.switchOrExit { wentOn = true } }
|
||||
awaitNode(inDialog(PENDING_QUESTION))
|
||||
type("Server port", "23x")
|
||||
awaitNode(inDialog("Apply")).performClick()
|
||||
awaitGone(inDialog(PENDING_QUESTION))
|
||||
|
||||
assertFalse(wentOn, "went on over a refused entry")
|
||||
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat, "applied despite the refusal")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun withNothingPendingTheSwitchOrExitGoesOnAtOnce() = runComposeUiTest {
|
||||
val w = Window()
|
||||
var wentOn = false
|
||||
show(w)
|
||||
|
||||
runOnIdle { w.switchOrExit { wentOn = true } }
|
||||
waitForIdle()
|
||||
|
||||
assertTrue(wentOn)
|
||||
assertFalse(w.closed)
|
||||
}
|
||||
|
||||
/*
|
||||
* An X while the exit or switch question is open must not replace its continuation:
|
||||
* Apply would then only close the window, and the exit would be dropped without a word.
|
||||
*/
|
||||
@Test
|
||||
fun theXDoesNotReplaceAnOpenSwitchOrExitQuestion() = runComposeUiTest {
|
||||
val w = Window()
|
||||
var wentOn = false
|
||||
show(w)
|
||||
toggle("Send the beacon in this category")
|
||||
|
||||
runOnIdle { w.switchOrExit { wentOn = true } }
|
||||
awaitNode(inDialog(PENDING_QUESTION))
|
||||
runOnIdle { w.x() }
|
||||
awaitNode(inDialog("Apply")).performClick()
|
||||
awaitGone(inDialog(PENDING_QUESTION))
|
||||
|
||||
assertTrue(wentOn, "the exit or switch was dropped")
|
||||
assertFalse(w.closed)
|
||||
}
|
||||
|
||||
/*
|
||||
* Invalid text in a field refuses Apply, but must not trap the operator: the question is
|
||||
* shown anyway, with the reason, and Discard closes.
|
||||
*/
|
||||
@Test
|
||||
fun anInvalidFieldStillLetsTheXDiscard() = runComposeUiTest {
|
||||
val w = Window()
|
||||
show(w)
|
||||
toggle("Send the beacon in this category")
|
||||
type("Server port", "23x")
|
||||
|
||||
runOnIdle { w.x() }
|
||||
awaitNode(inDialog(PENDING_QUESTION))
|
||||
awaitNode(inDialog("Apply")).assertIsNotEnabled()
|
||||
onNode(hasText("23x", substring = true) and hasAnyAncestor(isDialog())).assertExists()
|
||||
|
||||
awaitNode(inDialog("Discard")).performClick()
|
||||
awaitGone(inDialog(PENDING_QUESTION))
|
||||
|
||||
assertTrue(w.closed)
|
||||
assertFalse(w.prefs.isBcn_beaconsEnabledMainCat, "something was applied")
|
||||
}
|
||||
|
||||
/* The same for a switch or the exit, even when only the invalid field is unsettled. */
|
||||
@Test
|
||||
fun anInvalidFieldStillLetsASwitchOrExitDiscard() = runComposeUiTest {
|
||||
val w = Window()
|
||||
var wentOn = false
|
||||
show(w)
|
||||
type("Server port", "23x")
|
||||
|
||||
runOnIdle { w.switchOrExit { wentOn = true } }
|
||||
assertFalse(wentOn, "went on past a field that refuses its text")
|
||||
awaitNode(inDialog("Discard")).performClick()
|
||||
awaitGone(inDialog(PENDING_QUESTION))
|
||||
|
||||
assertTrue(wentOn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aContinuationRunsOnlyOnce() {
|
||||
var runs = 0
|
||||
val once = runOnce { runs++ }
|
||||
|
||||
once.run()
|
||||
once.run()
|
||||
|
||||
assertEquals(1, runs)
|
||||
}
|
||||
|
||||
/* Without a settings window there is nothing to ask: the action runs at once, synchronously. */
|
||||
@Test
|
||||
fun settleThenRunsAtOnceWithoutAnOpenWindow() {
|
||||
var ran = false
|
||||
SettingsWindow.settleThen { ran = true }
|
||||
assertTrue(ran)
|
||||
}
|
||||
|
||||
/* A failed effect does not keep the file from being written; its setting was stored. */
|
||||
@Test
|
||||
fun saveStillWritesWhenOnlyAnEffectFailed() {
|
||||
val prefs = ChatPreferences(directory.resolve("preferences.xml"))
|
||||
prefs.bcn_beaconIntervalInMinutesMainCat = 5
|
||||
val draft = SettingsDraft()
|
||||
val notices = SettingsNotices()
|
||||
val state = BeaconTabState(prefs, draft, { }, { throw IllegalStateException("timer gone") }, "Microwave", "144/432 MHz")
|
||||
var saved = false
|
||||
|
||||
state.commitBeaconInterval("9")
|
||||
applyAndSave(draft, notices) {
|
||||
saved = true
|
||||
"preferences.xml"
|
||||
}
|
||||
|
||||
assertTrue(saved)
|
||||
val notice = notices.current
|
||||
assertTrue(notice is SettingsNotice.Problem && notice.message.contains("timer gone") &&
|
||||
notice.message.contains("preferences.xml"), "$notice")
|
||||
}
|
||||
|
||||
/* Save applies first, so the file holds what the window shows; the window stays open. */
|
||||
@Test
|
||||
fun saveAppliesBeforeItWrites() {
|
||||
val prefs = ChatPreferences(directory.resolve("preferences.xml"))
|
||||
prefs.bcn_beaconIntervalInMinutesMainCat = 5
|
||||
val draft = SettingsDraft()
|
||||
val notices = SettingsNotices()
|
||||
val state = BeaconTabState(prefs, draft, { }, { }, "Microwave", "144/432 MHz")
|
||||
var intervalWhenSaved = -1
|
||||
|
||||
state.commitBeaconInterval("9")
|
||||
applyAndSave(draft, notices) {
|
||||
intervalWhenSaved = prefs.bcn_beaconIntervalInMinutesMainCat
|
||||
"preferences.xml"
|
||||
}
|
||||
|
||||
assertEquals(9, intervalWhenSaved, "the file was written before the draft was applied")
|
||||
assertFalse(draft.hasPendingChanges)
|
||||
assertTrue(notices.current is SettingsNotice.Info)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val APPLY_AND_CLOSE = "Apply/Close prefs"
|
||||
const val AIRSCOUT_PORT = "AirScout UDP port [9872] — reconnect after changing:"
|
||||
const val PENDING_QUESTION =
|
||||
"Some settings have been changed but not applied yet. Apply them first?"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import kst4contest.model.ChatPreferences
|
||||
import kst4contest.view.compose.ImmediateDraft
|
||||
import kst4contest.view.compose.SettingsDraft
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertSame
|
||||
@@ -10,13 +12,18 @@ import java.nio.file.Path
|
||||
|
||||
class ShortcutsTabStateTest(@TempDir private val tempDir: Path) {
|
||||
|
||||
private inner class Session {
|
||||
/*
|
||||
* The rules are pinned with a draft that applies at once; what the draft changes is
|
||||
* pinned by the tests that pass a real one.
|
||||
*/
|
||||
private inner class Session(val draft: SettingsDraft = ImmediateDraft()) {
|
||||
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||
var shortcutRefreshes = 0
|
||||
var snippetRefreshes = 0
|
||||
|
||||
fun state() = ShortcutsTabState(
|
||||
prefs,
|
||||
draft,
|
||||
refreshShortcutButtons = { shortcutRefreshes++ },
|
||||
refreshTextSnippetContextMenus = { snippetRefreshes++ },
|
||||
)
|
||||
@@ -35,7 +42,42 @@ class ShortcutsTabStateTest(@TempDir private val tempDir: Path) {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a new shortcut lands at the top and reaches the roster at once`() {
|
||||
fun `a list edit waits for apply and rebuilds the buttons once`() {
|
||||
val s = Session(SettingsDraft())
|
||||
s.prefs.getLst_txtShortCutBtnList().setAll(listOf("alt"))
|
||||
val state = s.state()
|
||||
|
||||
state.shortcuts.addAtTop("cq")
|
||||
state.commitShortcuts()
|
||||
state.shortcuts.addAtTop("tnx")
|
||||
state.commitShortcuts()
|
||||
|
||||
assertEquals(listOf("alt"), s.prefs.getLst_txtShortCutBtnList().snapshot(),
|
||||
"the list took effect before Apply")
|
||||
assertEquals(0, s.shortcutRefreshes)
|
||||
|
||||
s.draft.apply()
|
||||
|
||||
assertEquals(listOf("tnx", "cq", "alt"), s.prefs.getLst_txtShortCutBtnList().snapshot())
|
||||
assertEquals(1, s.shortcutRefreshes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a discarded list edit leaves the roster alone`() {
|
||||
val s = Session(SettingsDraft())
|
||||
s.prefs.getLst_txtSnipList().setAll(listOf("73"))
|
||||
val state = s.state()
|
||||
|
||||
state.snippets.commitAt(0, "")
|
||||
state.commitSnippets()
|
||||
s.draft.discard()
|
||||
|
||||
assertEquals(listOf("73"), s.prefs.getLst_txtSnipList().snapshot())
|
||||
assertEquals(0, s.snippetRefreshes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a new shortcut lands at the top and reaches the roster`() {
|
||||
val s = Session()
|
||||
s.prefs.getLst_txtShortCutBtnList().setAll(listOf("alt"))
|
||||
val state = s.state()
|
||||
|
||||
@@ -12,6 +12,8 @@ import androidx.compose.ui.test.onLast
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.compose.ui.test.performTextReplacement
|
||||
import androidx.compose.ui.test.runComposeUiTest
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import kst4contest.controller.On4KstConnectionState
|
||||
@@ -19,6 +21,7 @@ import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ChatPreferences
|
||||
import kst4contest.observe.UiDispatcher
|
||||
import kst4contest.view.compose.MainWindowSurroundings
|
||||
import kst4contest.view.compose.SettingsDraft
|
||||
import kst4contest.view.feed.ConnectionStateFeed
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
@@ -60,7 +63,8 @@ class StationTabScreenTest {
|
||||
}
|
||||
val mainApplied = mutableListOf<ChatCategory?>()
|
||||
val link = mutableStateOf(On4KstConnectionState.DISCONNECTED)
|
||||
val state = StationTabState(prefs, { mainApplied += it }, { }, { link.value })
|
||||
val draft = SettingsDraft()
|
||||
val state = StationTabState(prefs, draft, { mainApplied += it }, { }, { link.value })
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -77,9 +81,11 @@ class StationTabScreenTest {
|
||||
checkBeside("10 GHz").assertIsOff()
|
||||
}
|
||||
|
||||
/* The credentials write through; every other field waits for Apply. */
|
||||
@Test
|
||||
fun editingTheTextFieldsWritesThroughToThePreferences() = runComposeUiTest {
|
||||
fun editingTheTextFieldsStagesAllButTheCredentials() = runComposeUiTest {
|
||||
val s = Session(preferences())
|
||||
val storedHost = s.prefs.stn_on4kstServersDns
|
||||
showTab { StationTab(s.state) }
|
||||
|
||||
type("Callsign", "DO5AMF")
|
||||
@@ -88,6 +94,10 @@ class StationTabScreenTest {
|
||||
|
||||
assertEquals("DO5AMF", s.prefs.stn_loginCallSign)
|
||||
assertEquals("JO51IJ", s.prefs.stn_loginLocatorMainCat)
|
||||
assertEquals(storedHost, s.prefs.stn_on4kstServersDns, "the host took effect before Apply")
|
||||
fieldBeside("Server host name").assertTextEquals("www.on4kst.info")
|
||||
|
||||
runOnIdle { s.draft.apply() }
|
||||
assertEquals("www.on4kst.info", s.prefs.stn_on4kstServersDns)
|
||||
fieldBeside("Callsign").assertTextEquals("DO5AMF")
|
||||
}
|
||||
@@ -99,28 +109,77 @@ class StationTabScreenTest {
|
||||
showTab { StationTab(s.state) }
|
||||
|
||||
type("Server port", "23000")
|
||||
runOnIdle { s.draft.apply() }
|
||||
assertEquals(23000, s.prefs.stn_on4kstServersPort)
|
||||
|
||||
type("Server port", "23x")
|
||||
runOnIdle { s.draft.apply() }
|
||||
assertEquals(23000, s.prefs.stn_on4kstServersPort, "an unparsable port was written")
|
||||
|
||||
type("QTF", "135,5")
|
||||
runOnIdle { s.draft.apply() }
|
||||
assertEquals(135.5, s.prefs.stn_qtfDefault, 1e-9)
|
||||
}
|
||||
|
||||
/*
|
||||
* The draft is Compose state, so a staged number comes straight back to its field. The
|
||||
* field must keep the operator's text rather than redraw the number: "1" shown as "1.0"
|
||||
* before the next key would make "135,5" impossible to type.
|
||||
*/
|
||||
@Test
|
||||
fun aDecimalCanBeTypedKeyByKey() = runComposeUiTest {
|
||||
val s = Session(preferences())
|
||||
showTab { StationTab(s.state) }
|
||||
|
||||
val field = fieldBeside("QTF")
|
||||
field.performScrollTo().performClick()
|
||||
field.performTextReplacement("")
|
||||
"135,5".forEach { key ->
|
||||
field.performTextInput(key.toString())
|
||||
waitForIdle()
|
||||
}
|
||||
|
||||
field.assertTextEquals("135,5")
|
||||
runOnIdle { s.draft.apply() }
|
||||
assertEquals(135.5, s.prefs.stn_qtfDefault, 1e-9)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aBandCheckboxWritesThrough() = runComposeUiTest {
|
||||
fun aBandCheckboxIsStagedAndApplied() = runComposeUiTest {
|
||||
val s = Session(preferences())
|
||||
showTab { StationTab(s.state) }
|
||||
|
||||
toggle("10 GHz")
|
||||
assertTrue(s.prefs.isStn_bandActive10G)
|
||||
checkBeside("10 GHz").assertIsOn()
|
||||
assertFalse(s.prefs.isStn_bandActive10G, "the box took effect before Apply")
|
||||
|
||||
toggle("144 MHz")
|
||||
runOnIdle { s.draft.apply() }
|
||||
assertTrue(s.prefs.isStn_bandActive10G)
|
||||
assertFalse(s.prefs.isStn_bandActive144)
|
||||
}
|
||||
|
||||
/*
|
||||
* In a contest the session is up. Everything but the credentials stays editable then,
|
||||
* and an edit still waits for Apply.
|
||||
*/
|
||||
@Test
|
||||
fun theDraftedFieldsCanBeEditedWhileConnected() = runComposeUiTest {
|
||||
val s = Session(preferences())
|
||||
s.link.value = On4KstConnectionState.ONLINE
|
||||
showTab { StationTab(s.state) }
|
||||
|
||||
fieldBeside("Callsign").assertIsNotEnabled()
|
||||
fieldBeside("Server port").assertIsEnabled()
|
||||
toggle("10 GHz")
|
||||
type("Server port", "23005")
|
||||
|
||||
assertFalse(s.prefs.isStn_bandActive10G)
|
||||
runOnIdle { s.draft.apply() }
|
||||
assertTrue(s.prefs.isStn_bandActive10G)
|
||||
assertEquals(23005, s.prefs.stn_on4kstServersPort)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun choosingTheMainCategoryWritesAndAppliesIt() = runComposeUiTest {
|
||||
val s = Session(preferences())
|
||||
@@ -181,7 +240,7 @@ class StationTabScreenTest {
|
||||
override fun isUiThread(): Boolean = true
|
||||
}
|
||||
val feed = ConnectionStateFeed(surroundings, immediately)
|
||||
val state = StationTabState(preferences(), { }, { }, { surroundings.connectionState })
|
||||
val state = StationTabState(preferences(), SettingsDraft(), { }, { }, { surroundings.connectionState })
|
||||
showTab { StationTab(state) }
|
||||
|
||||
fieldBeside("Callsign").assertIsEnabled()
|
||||
|
||||
@@ -3,7 +3,10 @@ package kst4contest.view.compose.tabs
|
||||
import kst4contest.model.ChatCategory
|
||||
import kst4contest.model.ChatPreferences
|
||||
import org.junit.jupiter.api.Test
|
||||
import kst4contest.view.compose.SettingsDraft
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertNotEquals
|
||||
import org.junit.jupiter.api.Assertions.assertNull
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
@@ -16,8 +19,10 @@ class StationTabStateTest(@TempDir private val tempDir: Path) {
|
||||
var mainApplied: ChatCategory? = null
|
||||
var secondApplied: ChatCategory? = null
|
||||
var secondWasApplied = false
|
||||
val draft = SettingsDraft()
|
||||
val state = StationTabState(
|
||||
prefs,
|
||||
draft,
|
||||
{ mainApplied = it },
|
||||
{ secondApplied = it; secondWasApplied = true },
|
||||
)
|
||||
@@ -34,18 +39,55 @@ class StationTabStateTest(@TempDir private val tempDir: Path) {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `writing reaches the preferences at once, without a confirmation step`() {
|
||||
// The JavaFX window had no value-collecting confirmation: each control wrote
|
||||
// straight into ChatPreferences, and "Save settings" only persisted them.
|
||||
fun `a station setting waits in the draft until it is applied`() {
|
||||
val s = Session()
|
||||
|
||||
s.state.stn_pstRotatorEnabled = true
|
||||
s.state.stn_qtfDefault = 135.0
|
||||
s.state.stn_on4kstServersPort = 23002
|
||||
|
||||
assertFalse(s.prefs.isStn_pstRotatorEnabled(), "an edit took effect before Apply")
|
||||
assertNotEquals(23002, s.prefs.getStn_on4kstServersPort())
|
||||
assertTrue(s.state.stn_pstRotatorEnabled, "the tab shows the edited value")
|
||||
assertEquals(135.0, s.state.stn_qtfDefault, 0.001)
|
||||
|
||||
s.draft.apply()
|
||||
|
||||
assertTrue(s.prefs.isStn_pstRotatorEnabled())
|
||||
assertEquals(135.0, s.prefs.getStn_qtfDefault(), 0.001)
|
||||
assertEquals(23002, s.prefs.getStn_on4kstServersPort())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a discarded station setting leaves the preferences alone`() {
|
||||
val s = Session()
|
||||
s.prefs.setStn_bandActive144(true)
|
||||
|
||||
s.state.stn_bandActive144 = false
|
||||
s.state.stn_bandActive432 = true
|
||||
s.draft.discard()
|
||||
|
||||
assertTrue(s.prefs.isStn_bandActive144())
|
||||
assertFalse(s.prefs.isStn_bandActive432())
|
||||
assertTrue(s.state.stn_bandActive144, "after discarding the tab shows the stored value")
|
||||
}
|
||||
|
||||
/*
|
||||
* The credentials stay immediate: they are locked while a session exists, and without
|
||||
* one Connect must use what the tab shows.
|
||||
*/
|
||||
@Test
|
||||
fun `the login credentials still write through at once`() {
|
||||
val s = Session()
|
||||
|
||||
s.state.stn_loginCallSign = "DO5AMF"
|
||||
s.state.stn_pstRotatorEnabled = true
|
||||
s.state.stn_qtfDefault = 135.0
|
||||
s.state.stn_loginLocatorMainCat = "JO51IJ"
|
||||
s.state.stn_loginPassword = "secret"
|
||||
|
||||
assertEquals("DO5AMF", s.prefs.getStn_loginCallSign())
|
||||
assertTrue(s.prefs.isStn_pstRotatorEnabled())
|
||||
assertEquals(135.0, s.prefs.getStn_qtfDefault(), 0.001)
|
||||
assertEquals("JO51IJ", s.prefs.getStn_loginLocatorMainCat())
|
||||
assertEquals("secret", s.prefs.getStn_loginPassword())
|
||||
assertFalse(s.draft.hasPendingChanges, "a credential went into the draft")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -54,6 +96,7 @@ class StationTabStateTest(@TempDir private val tempDir: Path) {
|
||||
|
||||
s.state.stn_bandActive144 = true
|
||||
s.state.stn_bandActive432 = false
|
||||
s.draft.apply()
|
||||
|
||||
assertTrue(s.prefs.isStn_bandActive144())
|
||||
assertEquals(false, s.prefs.isStn_bandActive432())
|
||||
@@ -83,6 +126,7 @@ class StationTabStateTest(@TempDir private val tempDir: Path) {
|
||||
assertNull(s.prefs.getLoginChatCategorySecond(),
|
||||
"the JavaFX window wrote null for 'no second chat'")
|
||||
assertTrue(s.secondWasApplied, "the running session must learn about it too")
|
||||
assertFalse(s.draft.hasPendingChanges, "a chat category went into the draft")
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -90,9 +134,11 @@ class StationTabStateTest(@TempDir private val tempDir: Path) {
|
||||
// It moved here from the GUI tab: the JavaFX checkbox stood on the Station tab
|
||||
// next to the category pickers, and it decides what the Connect button does.
|
||||
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||
val state = StationTabState(prefs, { }, { })
|
||||
val draft = SettingsDraft()
|
||||
val state = StationTabState(prefs, draft, { }, { })
|
||||
|
||||
state.loginToSecondChatEnabled = true
|
||||
draft.apply()
|
||||
|
||||
assertTrue(prefs.isLoginToSecondChatEnabled())
|
||||
assertEquals(true, state.loginToSecondChatEnabled)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import kst4contest.model.ChatPreferences
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
import java.nio.file.Path
|
||||
|
||||
/** The corrections made once before the settings window is built, outside any draft. */
|
||||
class StoredSettingsRepairTest {
|
||||
|
||||
@TempDir
|
||||
lateinit var directory: Path
|
||||
|
||||
private fun preferences() = ChatPreferences(directory.resolve("preferences.xml"))
|
||||
|
||||
@Test
|
||||
fun `an unsupported fallback band prefix is written back as 144`() {
|
||||
val prefs = preferences()
|
||||
prefs.setNotify_optionalFrequencyPrefix("4711")
|
||||
|
||||
repairStoredSettings(prefs) { null }
|
||||
|
||||
assertEquals("144", prefs.getNotify_optionalFrequencyPrefix().get(),
|
||||
"the JavaFX window repaired the stored value the same way")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a still-default broadcast address is replaced by the detected one`() {
|
||||
val prefs = preferences()
|
||||
prefs.setLogsynch_wintestNetworkBroadcastAddress("255.255.255.255")
|
||||
|
||||
repairStoredSettings(prefs) { "192.168.178.255" }
|
||||
|
||||
assertEquals("192.168.178.255", prefs.getLogsynch_wintestNetworkBroadcastAddress(),
|
||||
"the JavaFX window auto-detected the subnet broadcast while the stored "
|
||||
+ "value was still the catch-all default")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a broadcast address the operator chose is never overwritten`() {
|
||||
val prefs = preferences()
|
||||
prefs.setLogsynch_wintestNetworkBroadcastAddress("10.0.0.255")
|
||||
|
||||
repairStoredSettings(prefs) { "192.168.178.255" }
|
||||
|
||||
assertEquals("10.0.0.255", prefs.getLogsynch_wintestNetworkBroadcastAddress())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed detection leaves the default in place`() {
|
||||
val prefs = preferences()
|
||||
prefs.setLogsynch_wintestNetworkBroadcastAddress("255.255.255.255")
|
||||
|
||||
repairStoredSettings(prefs) { throw IllegalStateException("no interface") }
|
||||
|
||||
assertEquals("255.255.255.255", prefs.getLogsynch_wintestNetworkBroadcastAddress())
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.runComposeUiTest
|
||||
import kst4contest.model.ChatPreferences
|
||||
import kst4contest.view.compose.ImmediateDraft
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
@@ -46,7 +47,11 @@ class SynchTabsScreenTest {
|
||||
}
|
||||
var restarts = 0
|
||||
var stops = 0
|
||||
val state = LogSynchTabState(prefs, { restarts++ }, { stops++ }, { detected })
|
||||
val state = run {
|
||||
// What the settings window does once before it builds the tabs.
|
||||
repairStoredSettings(prefs) { detected }
|
||||
LogSynchTabState(prefs, ImmediateDraft(), { restarts++ }, { stops++ })
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -63,7 +68,7 @@ class SynchTabsScreenTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun logSynchWritesThroughAndStartsTheWinTestListener() = runComposeUiTest {
|
||||
fun logSynchStoresItsFieldsAndStartsTheWinTestListener() = runComposeUiTest {
|
||||
val s = LogSynch(preferences())
|
||||
showTab { LogSynchTab(s.state) }
|
||||
|
||||
@@ -97,10 +102,10 @@ class SynchTabsScreenTest {
|
||||
// ------------------------------------------------------------------ TRX synch
|
||||
|
||||
@Test
|
||||
fun trxSynchWritesThroughAndSwitchesTheQrgFollower() = runComposeUiTest {
|
||||
fun trxSynchStoresItsFieldsAndSwitchesTheQrgFollower() = runComposeUiTest {
|
||||
val prefs = preferences()
|
||||
val follower = mutableListOf<Boolean>()
|
||||
val state = TrxSynchTabState(prefs) { follower += it }
|
||||
val state = TrxSynchTabState(prefs, ImmediateDraft()) { follower += it }
|
||||
showTab { TrxSynchTab(state) }
|
||||
|
||||
onNodeWithText("Receive UCXLog TRX info").assertExists()
|
||||
@@ -123,7 +128,7 @@ class SynchTabsScreenTest {
|
||||
fun airscoutWritesValidEntriesAndKeepsAnInvalidOneOffThePreferences() = runComposeUiTest {
|
||||
// The link defaults to on; start from off so a tick is a visible change.
|
||||
val prefs = preferences().apply { isAirScout_asUDPListenerEnabled = false }
|
||||
val state = AirscoutTabState(prefs)
|
||||
val state = AirscoutTabState(prefs, ImmediateDraft())
|
||||
showTab { AirscoutTab(state) }
|
||||
|
||||
onNodeWithText("AirScout UDP communication").assertExists()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package kst4contest.view.compose.tabs
|
||||
|
||||
import kst4contest.model.ChatPreferences
|
||||
import kst4contest.view.compose.SettingsDraft
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
@@ -13,7 +14,8 @@ class TrxSynchTabStateTest(@TempDir private val tempDir: Path) {
|
||||
private inner class Session {
|
||||
val prefs = ChatPreferences(tempDir.resolve("preferences.xml"))
|
||||
val followerCalls = mutableListOf<Boolean>()
|
||||
val state by lazy { TrxSynchTabState(prefs) { followerCalls.add(it) } }
|
||||
val draft = SettingsDraft()
|
||||
val state by lazy { TrxSynchTabState(prefs, draft) { followerCalls.add(it) } }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -31,45 +33,66 @@ class TrxSynchTabStateTest(@TempDir private val tempDir: Path) {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `writing reaches the preferences at once, without a confirmation step`() {
|
||||
fun `an edit waits for apply`() {
|
||||
val s = Session()
|
||||
|
||||
s.state.trxSynch_ucxLogUDPListenerEnabled = true
|
||||
s.state.logsynch_wintestQrgSyncEnabled = true
|
||||
s.state.logsynch_wintestUsePassQrg = true
|
||||
|
||||
assertFalse(s.prefs.isTrxSynch_ucxLogUDPListenerEnabled(), "an edit took effect before Apply")
|
||||
assertTrue(s.state.trxSynch_ucxLogUDPListenerEnabled, "the tab shows the edit")
|
||||
assertTrue(s.followerCalls.isEmpty(), "the follower moved before Apply")
|
||||
|
||||
s.draft.apply()
|
||||
|
||||
assertTrue(s.prefs.isTrxSynch_ucxLogUDPListenerEnabled())
|
||||
assertTrue(s.prefs.isLogsynch_wintestQrgSyncEnabled())
|
||||
assertTrue(s.prefs.isLogsynch_wintestUsePassQrg())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a discarded edit changes nothing`() {
|
||||
val s = Session()
|
||||
|
||||
s.state.trxSynch_ucxLogUDPListenerEnabled = true
|
||||
s.draft.discard()
|
||||
|
||||
assertFalse(s.prefs.isTrxSynch_ucxLogUDPListenerEnabled())
|
||||
assertTrue(s.followerCalls.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the Win-Test station name filter is stored trimmed, and an empty filter is allowed`() {
|
||||
// Empty means "accept all" — the JavaFX label said so explicitly.
|
||||
val s = Session()
|
||||
|
||||
s.state.logsynch_wintestNetworkStationNameOfWintestClient1 = " STN1 "
|
||||
s.draft.apply()
|
||||
assertEquals("STN1", s.prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1())
|
||||
|
||||
s.state.logsynch_wintestNetworkStationNameOfWintestClient1 = " "
|
||||
s.draft.apply()
|
||||
assertEquals("", s.prefs.getLogsynch_wintestNetworkStationNameOfWintestClient1())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the own-QRG follower follows whether any source is enabled`() {
|
||||
fun `the own-QRG follower follows whether any source is enabled, once per apply`() {
|
||||
val s = Session()
|
||||
|
||||
// Both sources in one apply: one call, with the finished state.
|
||||
s.state.trxSynch_ucxLogUDPListenerEnabled = true
|
||||
s.state.logsynch_wintestQrgSyncEnabled = false
|
||||
s.draft.apply()
|
||||
assertEquals(listOf(true), s.followerCalls)
|
||||
|
||||
s.state.logsynch_wintestQrgSyncEnabled = true
|
||||
assertEquals(listOf(true, true), s.followerCalls)
|
||||
|
||||
// One source off is not all sources off: the follower must stay attached.
|
||||
s.state.logsynch_wintestQrgSyncEnabled = true
|
||||
s.draft.apply()
|
||||
s.state.trxSynch_ucxLogUDPListenerEnabled = false
|
||||
s.draft.apply()
|
||||
assertEquals(listOf(true, true, true), s.followerCalls)
|
||||
|
||||
s.state.logsynch_wintestQrgSyncEnabled = false
|
||||
s.draft.apply()
|
||||
assertEquals(listOf(true, true, true, false), s.followerCalls)
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -6,6 +6,7 @@ import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.runComposeUiTest
|
||||
import kst4contest.model.ChatPreferences
|
||||
import kst4contest.view.compose.SettingsContent
|
||||
import kst4contest.view.compose.SettingsDraft
|
||||
import kst4contest.view.compose.SettingsNotices
|
||||
import kst4contest.view.compose.SettingsTab
|
||||
import kst4contest.view.compose.StationColumns
|
||||
@@ -37,9 +38,10 @@ class RemainingTextsFollowTheLanguageTest {
|
||||
var stored = "en"
|
||||
val store = LanguageStore({ stored }, { stored = it }, Locale.UK)
|
||||
val prefs = ChatPreferences(directory.resolve("preferences.xml"))
|
||||
val state = GuiOptionsTabState(prefs, store)
|
||||
val state = GuiOptionsTabState(prefs, SettingsDraft(), store)
|
||||
val beacon = BeaconTabState(
|
||||
prefs,
|
||||
SettingsDraft(),
|
||||
validateBeaconTemplate = {},
|
||||
restartBeaconTimer = {},
|
||||
mainCategoryName = "Microwave",
|
||||
|
||||
@@ -276,6 +276,21 @@ section records only the durable architecture and operational boundaries.
|
||||
|
||||
## Important Decisions and Workarounds
|
||||
|
||||
- **Settings take effect with Apply, not while they are edited** (release 1.50,
|
||||
`docs/superpowers/specs/2026-10-16-settings-apply-drafts-design.md`): the settings window
|
||||
stages every edit in a `SettingsDraft` that the tab states read through; Apply/Close, Save
|
||||
and Connect apply it; the X, a profile switch and the exit ask about what is pending first
|
||||
(`SettingsWindow.settleThen`, Back cancels). Fields still holding text -- typed but not
|
||||
committed, or invalid -- are checked before anything is applied, so a refusal leaves Discard
|
||||
possible. Apply writes everything first and then runs each effect key once, in first-changed
|
||||
order, so a listener or server whose switch and port both changed restarts once, on the new
|
||||
port; a failing write or effect is reported and the rest still runs.
|
||||
Immediate on purpose: login credentials (locked while connected), language, colours, and the
|
||||
actions (profiles, worked database, test spot, file chooser, stylesheet export). A new
|
||||
setting goes through `draft.setting(...)` and gets a row in `DraftedSettingsTableTest`; one
|
||||
that writes `ChatPreferences` directly bypasses Apply. Corrections of stored values that are
|
||||
no edit (fallback band, Win-Test broadcast detection) run once in `repairStoredSettings`
|
||||
before the window is built, never inside a tab state.
|
||||
- **The interface language has four kinds of text, and the rule is the addressee** (release
|
||||
1.50): interface text is translated; protocol text (the pipe-framed opcodes in
|
||||
`On4KstProtocol`, and the `/`-prefixed chat commands) and amateur shorthand (the
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
# Entwurf — Einstellungen frei bearbeiten, mit „Übernehmen" anwenden
|
||||
|
||||
Freigegeben vom Maintainer am 2026-10-16. Baut auf PR #128 auf (kein Festschreiben beim
|
||||
Öffnen, gesperrte Anmeldefelder während einer Sitzung).
|
||||
|
||||
## Warum
|
||||
|
||||
Bis hierher schrieb jedes Steuerelement des Einstellungsfensters beim Ändern sofort in
|
||||
`ChatPreferences`, und manche lösten dabei sofort eine Wirkung in der laufenden Sitzung aus:
|
||||
der Bakentimer startete neu, ein UDP-Listener wurde neu gebunden, der lokale
|
||||
DX-Cluster-Server gestartet oder gestoppt. Das war die Übernahme des JavaFX-Fensters, und es
|
||||
hat zwei Folgen, die im Contest stören:
|
||||
|
||||
- **Jeder Zwischenstand wirkt.** Wer einen Port von 9871 auf 9872 ändert, tippt über 987 und
|
||||
98 hinweg; wer zwei zusammengehörige Häkchen setzt, hat zwischendurch einen halben Stand
|
||||
aktiv. Ein Fehlgriff wirkt sofort und lässt sich nicht verwerfen.
|
||||
- **Es gibt kein „doch nicht".** Das Fenster hat keinen Weg zurück. „Übernehmen/Schließen"
|
||||
schloss nur, und wer etwas ausprobiert hatte, musste es von Hand zurückdrehen.
|
||||
|
||||
Das neue Verhalten ist das, das ein Operateur von einem Einstellungsdialog erwartet:
|
||||
bearbeiten, dann anwenden oder verwerfen.
|
||||
|
||||
## Verhalten
|
||||
|
||||
1. Das Öffnen des Fensters beginnt einen Entwurf. Felder, Häkchen und Auswahlen aller Reiter
|
||||
schreiben in den Entwurf statt in die Einstellungen; das Fenster zeigt den Entwurfswert.
|
||||
Bearbeiten geht auch während einer Sitzung.
|
||||
2. **Übernehmen/Schließen** wendet alle ausstehenden Änderungen an — Schreibvorgang samt
|
||||
Wirkung — und schließt das Fenster.
|
||||
3. **Einstellungen speichern** wendet zuerst an, dann schreibt es die Datei. Das Fenster
|
||||
bleibt offen.
|
||||
4. Das **X** der Titelleiste (und das Schließen über das Fenster-Menü) fragt bei
|
||||
ausstehenden Änderungen: Übernehmen, Verwerfen oder zurück zum Fenster. Ohne ausstehende
|
||||
Änderungen schließt es wie bisher.
|
||||
5. Ungültige Eingaben werden wie bisher am Feld abgewiesen und erreichen den Entwurf nie.
|
||||
Steht beim Übernehmen, Speichern oder Verbinden noch ungültiger Text in einem Feld (rot),
|
||||
gilt das als Abweisung: nichts wird angewendet, das Fenster bleibt offen, die Meldung nennt
|
||||
das Feld. Sonst ginge still der letzte gültige Wert in Kraft, den der Operateur nicht mehr
|
||||
sieht — und Verwerfen wäre nicht mehr möglich, weil die übrigen Änderungen schon wirkten.
|
||||
Erst wenn kein Feld etwas abweist, wird angewendet. Damit ein solches Feld nicht
|
||||
einsperrt, erscheint beim X, beim Profilwechsel und beim Beenden die Frage trotzdem: mit
|
||||
dem Grund, Übernehmen gesperrt, Verwerfen und Zurück verfügbar.
|
||||
6. **Verbinden** wendet ebenfalls zuerst an. Sonst verbände der Knopf mit dem gespeicherten
|
||||
statt mit dem sichtbaren Serverport oder Zweitchat-Häkchen — der Operateur sieht den einen
|
||||
Wert und bekommt den anderen.
|
||||
7. **Profilwechsel und Programmende** stellen dieselbe Frage, bevor sie weitergehen
|
||||
(`SettingsWindow.settleThen`): Übernehmen wendet die Änderungen auf das **bisherige**
|
||||
Profil an — das, in dem sie gemacht wurden — und setzt den Wechsel bzw. das Beenden fort;
|
||||
Verwerfen setzt ohne sie fort; Zurück bricht Wechsel bzw. Beenden ab. Ohne offene
|
||||
Änderungen geht es sofort und wie bisher weiter. Solange eine Frage offen ist, werden
|
||||
weitere Anfragen ignoriert — ein X darf die Fortsetzung eines Beendens nicht durch ein
|
||||
bloßes Schließen ersetzen —, und jede Fortsetzung läuft höchstens einmal. Erst der anschließende Abbau
|
||||
(`SettingsWindow.close`) und ein JVM-Shutdown-Hook verwerfen ohne Frage.
|
||||
8. **Scheitert beim Anwenden ein Schreibvorgang oder eine Wirkung**, laufen alle übrigen
|
||||
trotzdem; die Fehler stehen danach in einer Problem-Meldung, das Fenster bleibt offen. Eine
|
||||
Einstellung, deren Schreibvorgang scheiterte, bleibt im Entwurf, ihre Wirkung läuft nicht.
|
||||
Speichern schreibt die Datei, wenn alle Schreibvorgänge gelangen, auch wenn eine Wirkung
|
||||
scheiterte — deren Einstellung ist gespeichert und gehört in die Datei.
|
||||
|
||||
## Was sofort wirkt, wie bisher
|
||||
|
||||
- **Farben** (Reiter Farben) und **Sprache** (Reiter GUI). Beide sind sichtbar, sobald sie
|
||||
wirken; ein Entwurf hätte dort nichts zu zeigen.
|
||||
- **Anmeldefelder** des Reiters Station: Rufzeichen, beide Namen, Locator, Passwort, beide
|
||||
Chatkategorien. Sie sind laut #128 während einer Sitzung gesperrt; ohne Sitzung wirken sie
|
||||
ohnehin erst beim nächsten Verbinden.
|
||||
- **Aktionen**: Profil anlegen, duplizieren, umbenennen, löschen, aktivieren; die Aktionen
|
||||
der Worked-Datenbank; der Test-Spot des DX-Clusters; die „Choose…"-Dateiauswahl;
|
||||
der Export des Stylesheets. Das sind Handlungen, keine Werte.
|
||||
- **Reparaturen gespeicherter Werte** (`repairStoredSettings`), einmal beim Öffnen des
|
||||
Fensters, bevor ein Reiter gebaut wird: eine unlesbare Ausweichband-Vorgabe des
|
||||
DX-Clusters wird zu 144, und die Win-Test-Broadcastadresse wird, solange sie noch der
|
||||
Platzhalter 255.255.255.255 ist, durch die erkannte Subnetz-Adresse ersetzt. Beides schrieb
|
||||
schon das JavaFX-Fenster beim Aufbau, beides ist keine Eingabe des Operateurs und hat
|
||||
nichts zum Übernehmen oder Verwerfen — darum direkt in `ChatPreferences`. Früher geschah
|
||||
das in den Reiterzuständen, eines davon beim Lesen während der Komposition; Compose liest
|
||||
wann es will, und ein Schreiben dort ist leicht zu übersehen. Jetzt gibt es dafür einen
|
||||
bekannten Zeitpunkt, und kein Reiterzustand schreibt beim Lesen oder Bauen.
|
||||
|
||||
Unverändert bleiben das Format von `preferences.xml` und die Knöpfe der Verbindungsleiste.
|
||||
|
||||
## Mechanismus
|
||||
|
||||
`SettingsDraft` ist ein Fensterobjekt, das einmal pro Öffnen entsteht und an alle
|
||||
Reiterzustände geht. Er hält ausstehende Änderungen, eine pro Einstellung, unter einem
|
||||
Schlüssel:
|
||||
|
||||
```kotlin
|
||||
draft.stage(key, value, live = { prefs.getX() }, write = { prefs.setX(it) },
|
||||
effect = ::restartSomething, effectKey = "something")
|
||||
draft.valueOf(key) { prefs.getX() } // Entwurfswert oder der gespeicherte
|
||||
draft.commitTypedEntries() // getippter Text der Felder → Entwurf, oder Abweisungen
|
||||
draft.apply() // Schreiben, dann Wirkungen; liefert die Fehlschläge
|
||||
draft.discard()
|
||||
draft.hasPendingChanges // Compose-Zustand
|
||||
```
|
||||
|
||||
- **Lesen geht durch den Entwurf.** Die Getter der Reiterzustände lesen
|
||||
`draft.valueOf(key) { prefs… }`. Die Ablage ist Compose-Zustand, also zeichnet ein Feld nach
|
||||
einer Änderung neu — die alten `revision`-Zähler der Reiter werden dafür nicht mehr gebraucht,
|
||||
schaden aber nicht.
|
||||
- **Zurück auf den gespeicherten Wert heißt: nichts steht aus.** Ein Wert gleich dem
|
||||
gespeicherten nimmt den Eintrag heraus. Ein Häkchen hin und zurück lässt keine Frage beim
|
||||
Schließen zurück und keinen Neustart beim Übernehmen.
|
||||
- **`Form.committed`** bleibt wie in #128: Fokusverlust oder Enter rufen `commit`, und das
|
||||
schreibt jetzt in den Entwurf. `current` liest über den Reiterzustand den Entwurfs- oder den
|
||||
gespeicherten Wert. Damit gelten die drei Zusagen von #128 weiter: kein Festschreiben beim
|
||||
Öffnen, kein doppeltes, kein verlorenes.
|
||||
- **Listen** (Kurzbefehle, Textbausteine, beobachtete Rufzeichen) halten ihren Arbeitsstand
|
||||
schon in `EditableListState`; der Entwurf merkt sich nur, dass die Liste geändert ist, und
|
||||
`apply` füllt den Roster aus diesem Stand.
|
||||
|
||||
## Reihenfolge beim Anwenden
|
||||
|
||||
1. **Erst alle Schreibvorgänge**, in der Reihenfolge, in der die Einstellungen zum ersten Mal
|
||||
geändert wurden.
|
||||
2. **Dann die Wirkungen**, in derselben Reihenfolge, **jede Wirkung einmal**. Wirkungen tragen
|
||||
einen Schlüssel; teilen sich zwei Einstellungen einen, läuft er einmal.
|
||||
3. Dann ist der Entwurf leer.
|
||||
|
||||
Schreiben vor Wirken ist nicht Geschmack. Win-Test-Listener ein **und** neuer Port: liefe die
|
||||
Wirkung zwischen den Schreibvorgängen, bände der Listener noch den alten Port. Erst alle
|
||||
Werte, dann die Wirkungen heißt: jede Wirkung sieht den fertigen Stand.
|
||||
|
||||
Einmal pro Schlüssel ist ebenfalls nicht Geschmack. Zwei kurz nacheinander neu gebundene
|
||||
Sockets auf demselben Port können am noch nicht freigegebenen ersten scheitern. Die
|
||||
geteilten Schlüssel:
|
||||
|
||||
| Schlüssel | Einstellungen | Wirkung (auf dem fertigen Stand) |
|
||||
|---|---|---|
|
||||
| Win-Test-Listener | Listener ein/aus, Port | an → Neustart, aus → Stopp |
|
||||
| DX-Cluster-Server | Server ein/aus, Port | nur angemeldet: an → Neustart, aus → Stopp |
|
||||
| Eigene-QRG-Folger | UCXLog-QRG, Win-Test-QRG | Folger an, wenn eine der beiden Quellen an ist |
|
||||
| Bakentimer | Intervall | Neustart |
|
||||
| Mitschnitt in Datei | Häkchen | Fehlerprotokoll und Recorder öffnen oder schließen |
|
||||
| Kurzbefehle / Textbausteine | Listen | Knöpfe bzw. Kontextmenüs neu bauen |
|
||||
|
||||
Die geteilten Wirkungen gleichen den laufenden Dienst an den fertigen Stand an, statt zu
|
||||
fragen, welche der beiden Einstellungen sich geändert hat. Das deckt alle Fälle der sofortigen
|
||||
Änderung ab: Einschalten startet (ein Neustart eines nicht laufenden Dienstes ist ein Start),
|
||||
Ausschalten stoppt, ein neuer Port bei laufendem Dienst bindet neu. Ein neuer Port bei
|
||||
abgeschaltetem Dienst ruft einen Stopp auf einem nicht laufenden Dienst; der tut nichts, wie
|
||||
zuvor die ausgebliebene Wirkung.
|
||||
|
||||
Zwischen verschiedenen Schlüsseln gibt es keine Abhängigkeit; ihre Reihenfolge ist die der
|
||||
ersten Änderung und damit dieselbe, in der die sofortigen Änderungen sie bisher ausgelöst
|
||||
hätten.
|
||||
|
||||
## Fäden
|
||||
|
||||
Alles läuft auf dem AWT-Ereignisfaden, wie bisher die sofortigen Änderungen: Knopfdruck,
|
||||
Dialogwahl, Menüeintrag. Gewartet wird dort nie. Die Entscheidung — weiter, fragen —
|
||||
fällt in `SettingsQuestion.settle` sofort auf dem Ereignisfaden, nicht im nächsten Bild des
|
||||
Einstellungsfensters: ob ein minimiertes Compose-Fenster weiter Bilder erzeugt, ist ohne
|
||||
laufende Oberfläche nicht prüfbar, und ein Beenden über das Hauptfenster darf davon nicht
|
||||
abhängen. Ohne offene Änderungen geht es sofort weiter. Gibt es etwas zu fragen, wird das
|
||||
Fenster nach vorn geholt und, falls minimiert, wiederhergestellt; erst danach braucht es ein
|
||||
Bild, nämlich für den Dialog. Die Fortsetzung (Wechsel, Beenden) läuft aus dem Rückruf des
|
||||
Dialogs, über `EventQueue.invokeLater`, nicht im Klick-Handler selbst.
|
||||
|
||||
## Prüfung
|
||||
|
||||
Pro Einstellung (`DraftedSettingsTableTest`, mit echtem Entwurf): unverändert nach der
|
||||
Eingabe, geändert nach Übernehmen, unverändert nach Verwerfen. Pro Fenster: Speichern wendet
|
||||
an und schreibt; X, Profilwechsel und Programmende fragen bei offenen Änderungen; Zurück
|
||||
bricht ab; ungültiger Text und Fehlschläge halten das Fenster offen; Bearbeiten während einer
|
||||
Sitzung geht.
|
||||
@@ -36,6 +36,8 @@ Mehrere Operateure an einem Rechner können jetzt eigene Rufzeichen, Locators un
|
||||
|
||||
- **Dark Mode pro Profil:** Im Reiter **GUI** legt jedes Operator-Profil unter **Design** fest, ob KST4Contest hell oder dunkel startet. Die Menüeinträge unter **Windows** schalten weiterhin schnell um, gelten aber nur für die laufende Sitzung. Das Farbschema erfasst jetzt auch das Update-Fenster und die Liste der Top-Kandidaten.
|
||||
|
||||
- **Einstellungen wirken erst mit Übernehmen:** Änderungen im Einstellungsfenster wirken nicht mehr während der Eingabe, sondern erst mit **Übernehmen/Schließen** oder **Einstellungen speichern**; **Verbinden** übernimmt sie ebenfalls vorher. Das gilt auch während einer laufenden Sitzung. Wer mit offenen Änderungen das Fenster schließt, das Profil wechselt oder das Programm beendet, wird zuerst gefragt, ob sie übernommen oder verworfen werden sollen, oder kann zurück. Farben, Sprache und die Anmeldedaten im Reiter **Station** wirken wie bisher sofort.
|
||||
|
||||
### Behoben
|
||||
|
||||
- **Einheitliche Schriftgröße:** Unter macOS und Linux verwendet JavaFX eine etwas größere Standardschrift als unter Windows. Dadurch wurden Beschriftungen in fest bemessenen Bedienelementen abgeschnitten. KST4Contest verwendet jetzt auf allen Systemen dieselbe Grundschriftgröße; unter Windows ändert sich nichts.
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||
Nach dem ersten Start öffnet sich das **Einstellungsfenster** – dieses ist der zentrale Ausgangspunkt für alle Konfigurationen. Es empfiehlt sich, das Einstellungsfenster während des Betriebs geöffnet zu lassen (z. B. um den Beacon schnell ein- und auszuschalten).
|
||||
|
||||
> **Wichtig**: Fachliche Einstellungen mit **„Save Settings"** sichern, wenn sie beim nächsten Start wieder gelten sollen. Layoutänderungen wie Fenstergrößen, Divider-Positionen und Tabellenbreiten speichert KST4Contest automatisch. Die gemeinsame Datei liegt unter Linux und macOS in `~/.praktiKST/preferences.xml` und unter Windows in `%USERPROFILE%\.praktiKST\preferences.xml` (bzw. `C:\Users\<Benutzername>\.praktiKST\preferences.xml`).
|
||||
> **Wichtig**: Änderungen im Einstellungsfenster wirken, sobald **Apply/Close prefs** oder **Save Settings** gedrückt wird; **Connect** übernimmt sie ebenfalls vorher. Wer mit offenen Änderungen das Fenster schließt, das Profil wechselt oder das Programm beendet, wird zuerst gefragt, ob sie übernommen oder verworfen werden sollen, oder kann zurück. Farben, Sprache und die Anmeldedaten im Reiter **Station** wirken sofort.
|
||||
>
|
||||
> Fachliche Einstellungen mit **„Save Settings"** sichern, wenn sie beim nächsten Start wieder gelten sollen. Layoutänderungen wie Fenstergrößen, Divider-Positionen und Tabellenbreiten speichert KST4Contest automatisch. Die gemeinsame Datei liegt unter Linux und macOS in `~/.praktiKST/preferences.xml` und unter Windows in `%USERPROFILE%\.praktiKST\preferences.xml` (bzw. `C:\Users\<Benutzername>\.praktiKST\preferences.xml`).
|
||||
|
||||
---
|
||||
|
||||
@@ -139,7 +141,7 @@ Die TRX-Synchronisation übernimmt die aktuelle Frequenz aus dem Logprogramm und
|
||||
|
||||
Der allgemeine Listener ist für Logprogramme vorgesehen, die kompatible `RadioInfo`-Pakete senden. Dazu gehören – abhängig von deren jeweiliger Konfiguration – UCXLog, N1MM+, QARTest und DXLog.net. QSO- und `RadioInfo`-Pakete verwenden denselben unter **Log sync** konfigurierten Port. Dort wird jedoch getrennt festgelegt, ob KST4Contest QSO-Informationen, TRX-Informationen oder beide Paketarten verarbeitet. Eine automatische QRG-Quelle muss aktiviert sein und tatsächlich gültige Pakete liefern; die Aktivierung allein aktualisiert `MYQRG` nicht.
|
||||
|
||||
Wird der gemeinsame UDP-Port geändert, muss KST4Contest neu gestartet werden. Eine reine Änderung der Checkboxen wird dagegen sofort berücksichtigt.
|
||||
Wird der gemeinsame UDP-Port geändert, muss KST4Contest neu gestartet werden. Eine reine Änderung der Checkboxen wird dagegen berücksichtigt, sobald sie übernommen ist.
|
||||
|
||||
### Welche QRG wird aktualisiert?
|
||||
|
||||
@@ -303,7 +305,7 @@ Die Auswahl begrenzt nicht nur den Netzwerkverkehr. Sie verhindert außerdem, da
|
||||
|
||||
### Übernahme geänderter Einstellungen
|
||||
|
||||
Folgende Änderungen werden nach Verlassen des Eingabefeldes beziehungsweise Betätigen der Checkbox sofort für neue Pakete verwendet:
|
||||
Folgende Änderungen werden für neue Pakete verwendet, sobald sie übernommen sind:
|
||||
|
||||
- Aktivierung oder Deaktivierung der AirScout-Integration,
|
||||
- Server-Identifier,
|
||||
@@ -368,7 +370,7 @@ Mehr zur Erkennungslogik und zu absichtlich ignorierten Zahlen: [QRG-Erkennung](
|
||||
|
||||
KST4Contest kann erkannte Richtungsgelegenheiten als DX-Cluster-Spots an ein Logprogramm weitergeben. Eine im Chat erkannte Frequenz erscheint dadurch direkt in der Bandmap des Logprogramms und muss nicht erst von Hand übertragen werden.
|
||||
|
||||
Die Checkbox **Enable the local DX Cluster server …** startet beziehungsweise beendet den lokalen TCP-Server. Bei einer laufenden Chat-Verbindung wird die Änderung sofort wirksam.
|
||||
Die Checkbox **Enable the local DX Cluster server …** startet beziehungsweise beendet den lokalen TCP-Server. Bei einer laufenden Chat-Verbindung wird die Änderung wirksam, sobald sie übernommen ist.
|
||||
|
||||
Folgende Einstellungen und Schaltflächen gehören zur lokalen DX-Cluster-Ausgabe:
|
||||
|
||||
@@ -477,7 +479,7 @@ Rufzeichen werden folgendermaßen verwaltet:
|
||||
|
||||
Die Eingabe darf ein sichtbares KST-Suffix oder portable Bestandteile enthalten. KST4Contest normalisiert sie vor dem Speichern auf das Basisrufzeichen. Mehrere Varianten desselben Basisrufzeichens gelten deshalb als Duplikat.
|
||||
|
||||
Die Änderung der Liste wirkt sofort. Zum dauerhaften Speichern anschließend **Save Settings** verwenden. Die Basisrufzeichen werden in der `preferences.xml` gespeichert und beim nächsten Programmstart wiederhergestellt.
|
||||
Die Änderung der Liste wirkt, sobald sie übernommen ist. Zum dauerhaften Speichern anschließend **Save Settings** verwenden. Die Basisrufzeichen werden in der `preferences.xml` gespeichert und beim nächsten Programmstart wiederhergestellt.
|
||||
|
||||
> Die Zusammenführung der KST-Suffixe über das Basisrufzeichen ist ab v1.42 enthalten.
|
||||
|
||||
@@ -514,7 +516,7 @@ Die Reihenfolge der Tabelle entspricht der Reihenfolge der Schaltflächen im Hau
|
||||
3. Wird der Inhalt vollständig gelöscht und anschließend mit `Enter` bestätigt, entfernt KST4Contest den Eintrag.
|
||||
4. Mit **Move selected up** und **Move selected down** wird der markierte Eintrag innerhalb der Liste verschoben.
|
||||
|
||||
Änderungen werden sofort im Hauptfenster sichtbar. Damit sie auch nach dem nächsten Programmstart erhalten bleiben, anschließend **Save Settings** verwenden.
|
||||
Übernommene Änderungen werden sofort im Hauptfenster sichtbar. Damit sie auch nach dem nächsten Programmstart erhalten bleiben, anschließend **Save Settings** verwenden.
|
||||
|
||||
---
|
||||
|
||||
@@ -563,7 +565,7 @@ Die Snippet-Liste wird genauso bearbeitet wie die Shortcut-Liste:
|
||||
4. Ein leer bestätigter Eintrag wird entfernt.
|
||||
5. **Move selected up** und **Move selected down** ändern die Reihenfolge und damit auch die Zuordnung zu `Ctrl+1` bis `Ctrl+0`.
|
||||
|
||||
Die Kontextmenüs und Tastenkombinationen werden nach einer Änderung sofort aktualisiert. Für die dauerhafte Speicherung anschließend **Save Settings** verwenden.
|
||||
Die Kontextmenüs und Tastenkombinationen werden aktualisiert, sobald die Änderung übernommen ist. Für die dauerhafte Speicherung anschließend **Save Settings** verwenden.
|
||||
|
||||
Eine vollständige Übersicht der verfügbaren Platzhalter und ihrer Grenzen steht unter [Makros und Variablen](de-Makros-und-Variablen).
|
||||
|
||||
@@ -872,7 +874,7 @@ Im Reiter **GUI** lassen sich zwei Zusatzinformationen der Bandspalten ein- oder
|
||||
- **Show "o" in band columns …** zeigt ein `o`, wenn das vierstellige Großfeld auf dem betreffenden Band bereits gearbeitet wurde. Das Abschalten entfernt keine Daten aus der Datenbank; nur die zusätzliche Anzeige in den Bandspalten wird ausgeblendet. `wkdany` bleibt davon unberührt.
|
||||
- **Show "a" in band columns …** unterscheidet ein vollständig neues Rufzeichen von einer Bandmöglichkeit mit einem bereits auf einem anderen Band gearbeiteten Rufzeichen. Ist die Option ausgeschaltet, werden beide Fälle als `B+` dargestellt. Die Bandherleitung selbst ändert sich dadurch nicht.
|
||||
|
||||
Änderungen werden in der laufenden Benutzeroberfläche unmittelbar sichtbar. Damit sie nach dem nächsten Programmstart erhalten bleiben, anschließend **Save Settings** verwenden.
|
||||
Übernommene Änderungen werden in der laufenden Benutzeroberfläche unmittelbar sichtbar. Damit sie nach dem nächsten Programmstart erhalten bleiben, anschließend **Save Settings** verwenden.
|
||||
|
||||

|
||||
|
||||
@@ -946,7 +948,7 @@ Der Reiter **Profiles** im Einstellungsfenster zeigt alle Profile mit Name, Art
|
||||
|
||||
### Profil im laufenden Betrieb wechseln
|
||||
|
||||
**File → Switch operator profile...** oder die Schaltfläche im Reiter **Profiles** wechseln ohne Programmneustart. Nach einer Sicherheitsabfrage wird die ON4KST-Verbindung getrennt und die Oberfläche mit den Einstellungen und dem Layout des gewählten Profils neu aufgebaut. Der Layoutstand des bisherigen Profils wird vorher gesichert; noch nicht mit **Save Settings** bestätigte fachliche Änderungen gehen dabei verloren.
|
||||
**File → Switch operator profile...** oder die Schaltfläche im Reiter **Profiles** wechseln ohne Programmneustart. Nach einer Sicherheitsabfrage wird die ON4KST-Verbindung getrennt und die Oberfläche mit den Einstellungen und dem Layout des gewählten Profils neu aufgebaut. Hält das Einstellungsfenster noch nicht übernommene Änderungen, fragt KST4Contest zuerst, ob sie auf das bisherige Profil übernommen, verworfen oder der Wechsel abgebrochen werden soll. Der Layoutstand des bisherigen Profils wird vorher gesichert; noch nicht mit **Save Settings** bestätigte fachliche Änderungen gehen dabei verloren.
|
||||
|
||||
Sobald mehr als ein Profil existiert, zeigt die Titelzeile des Hauptfensters zusätzlich den Profilnamen.
|
||||
|
||||
@@ -958,7 +960,7 @@ Das ON4KST-Passwort steht wie bisher im Klartext in der `preferences.xml` des je
|
||||
|
||||
## Dark Mode (ab v1.26)
|
||||
|
||||
Mit welchem Farbschema KST4Contest startet, legt jedes Operator-Profil selbst fest: im Reiter **GUI** unter **Design** mit **Light mode** oder **Dark mode**. Die Auswahl wirkt sofort und wird mit **Save Settings** in der `preferences.xml` des Profils gespeichert.
|
||||
Mit welchem Farbschema KST4Contest startet, legt jedes Operator-Profil selbst fest: im Reiter **GUI** unter **Design** mit **Light mode** oder **Dark mode**. Die Auswahl wirkt, sobald sie übernommen ist, und wird mit **Save Settings** in der `preferences.xml` des Profils gespeichert.
|
||||
|
||||
Für einen kurzen Wechsel während des Betriebs gibt es **Windows → Use dark mode design** und **Windows → Use default mode design**. Diese Umschaltung gilt nur bis zum nächsten Start oder Profilwechsel; danach gilt wieder die Einstellung im Reiter **GUI**.
|
||||
|
||||
@@ -966,7 +968,7 @@ Für einen kurzen Wechsel während des Betriebs gibt es **Windows → Use dark m
|
||||
|
||||
## Einstellungen speichern
|
||||
|
||||
**Save Settings** speichert die fachlichen Einstellungen und den vollständigen aktuellen Layoutstand. Änderungen an Fenstergrößen und -positionen, relevanten Dividern, verwalteten Tabellenbreiten sowie der Karteneinstellung **Group nearby stations** werden zusätzlich automatisch mit kurzer Verzögerung gespeichert. Ein ausstehender Layoutstand wird beim Programmende noch geschrieben.
|
||||
**Save Settings** übernimmt zuerst offene Änderungen und speichert dann die fachlichen Einstellungen und den vollständigen aktuellen Layoutstand. Änderungen an Fenstergrößen und -positionen, relevanten Dividern, verwalteten Tabellenbreiten sowie der Karteneinstellung **Group nearby stations** werden zusätzlich automatisch mit kurzer Verzögerung gespeichert. Ein ausstehender Layoutstand wird beim Programmende noch geschrieben.
|
||||
|
||||
- Speicherort: unter Linux und macOS `~/.praktiKST/preferences.xml` und unter Windows `%USERPROFILE%\.praktiKST\preferences.xml` (bzw. `C:\Users\<Benutzername>\.praktiKST\preferences.xml`)
|
||||
- Der automatische Layout-Writer übernimmt keine noch nicht mit **Save Settings** bestätigten fachlichen Änderungen.
|
||||
|
||||
@@ -36,6 +36,8 @@ Several operators sharing one computer can now use their own callsigns, locators
|
||||
|
||||
- **Dark mode per profile:** on the **GUI** tab each operator profile sets under **Design** whether KST4Contest starts light or dark. The entries in the **Windows** menu still switch quickly, but only for the running session. The colour scheme now also covers the update window and the top candidates list.
|
||||
|
||||
- **Settings take effect with Apply:** changes in the settings window no longer take effect while they are typed, but with **Apply/Close prefs** or **Save settings**; **Connect** applies them first as well. This also works during a running session. Closing the window, switching the operator profile or exiting with changes pending first asks whether to apply or discard them, or to go back. Colours, language and the login data on the **Station** tab still take effect immediately.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Consistent font size:** on macOS and Linux JavaFX uses a slightly larger default font than on Windows, which cut off labels in fixed-size controls. KST4Contest now uses the same base font size on all systems; nothing changes on Windows.
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||
After the first start, the **settings window** opens – this is the central starting point for all configuration. It is recommended to keep the settings window open during operation (e.g. to quickly toggle the beacon on and off).
|
||||
|
||||
> **Important**: Use **Save Settings** for functional settings which should remain in effect after the next start. KST4Contest saves layout changes such as window sizes, divider positions and table-column widths automatically. The shared file is `~/.praktiKST/preferences.xml` on Linux and macOS and `%USERPROFILE%\.praktiKST\preferences.xml` (or `C:\Users\<Username>\.praktiKST\preferences.xml`) on Windows.
|
||||
> **Important**: Changes in the settings window take effect when you click **Apply/Close prefs** or **Save Settings**; **Connect** applies them first as well. Closing the window, switching the operator profile or exiting with changes pending first asks whether to apply or discard them, or to go back. Colours, the language and the login data on the **Station** tab take effect immediately.
|
||||
>
|
||||
> Use **Save Settings** for functional settings which should remain in effect after the next start. KST4Contest saves layout changes such as window sizes, divider positions and table-column widths automatically. The shared file is `~/.praktiKST/preferences.xml` on Linux and macOS and `%USERPROFILE%\.praktiKST\preferences.xml` (or `C:\Users\<Username>\.praktiKST\preferences.xml`) on Windows.
|
||||
|
||||
---
|
||||
|
||||
@@ -158,7 +160,7 @@ TRX synchronisation imports the current frequency from the logging application a
|
||||
|
||||
The general listener is intended for logging applications which transmit compatible `RadioInfo` packets. Depending on their individual configuration, this includes UCXLog, N1MM+, QARTest and DXLog.net. QSO and `RadioInfo` packets use the same port configured under **Log sync**, but separate options determine whether KST4Contest processes QSO information, TRX information or both packet types. An automatic QRG source must be enabled and actually supply valid packets; enabling it alone does not update `MYQRG`.
|
||||
|
||||
Restart KST4Contest after changing the shared UDP port. Changes to the two QRG-sync checkboxes take effect immediately.
|
||||
Restart KST4Contest after changing the shared UDP port. Changes to the two QRG-sync checkboxes take effect as soon as they are applied.
|
||||
|
||||
### Which QRG Is Updated?
|
||||
|
||||
@@ -356,7 +358,7 @@ This selection does more than reduce network traffic. It also prevents AirScout
|
||||
|
||||
### Applying Changed Settings
|
||||
|
||||
The following changes are used for new packets immediately after leaving the input field or changing the checkbox:
|
||||
The following changes are used for new packets as soon as they are applied:
|
||||
|
||||
- enabling or disabling the AirScout integration,
|
||||
- server identifier,
|
||||
@@ -417,7 +419,7 @@ Further details, including numbers which are deliberately ignored, are described
|
||||
|
||||
KST4Contest can forward detected directional opportunities to logging software as DX cluster spots. A frequency recognised in the chat can therefore appear directly in the logger's band map without being entered manually.
|
||||
|
||||
The **Enable the local DX Cluster server …** checkbox starts or stops the local TCP server. When KST4Contest is connected to the chat, the change takes effect immediately.
|
||||
The **Enable the local DX Cluster server …** checkbox starts or stops the local TCP server. When KST4Contest is connected to the chat, the change takes effect as soon as it is applied.
|
||||
|
||||
The following settings and controls belong to the local DX cluster output:
|
||||
|
||||
@@ -514,7 +516,7 @@ The order of the table determines the order of the buttons in the main window. M
|
||||
3. To remove an entry, delete its complete contents and confirm with `Enter`.
|
||||
4. Use **Move selected up** and **Move selected down** to change the position of the selected entry.
|
||||
|
||||
Changes appear in the main window immediately. Use **Save Settings** afterwards if they should remain available after the next program start.
|
||||
Applied changes appear in the main window at once. Use **Save Settings** afterwards if they should remain available after the next program start.
|
||||
|
||||
---
|
||||
|
||||
@@ -563,7 +565,7 @@ The snippet list is edited in the same way as the shortcut list:
|
||||
4. Confirming an empty entry removes it.
|
||||
5. **Move selected up** and **Move selected down** change both the displayed order and the assignment to `Ctrl+1` through `Ctrl+0`.
|
||||
|
||||
The context menus and keyboard mappings are updated immediately. Use **Save Settings** afterwards to store the modified list permanently.
|
||||
The context menus and keyboard mappings are updated as soon as the change is applied. Use **Save Settings** afterwards to store the modified list permanently.
|
||||
|
||||
The complete list of available placeholders and their limitations is described under [Macros and Variables](en-Macros-and-Variables).
|
||||
|
||||
@@ -914,7 +916,7 @@ Manage the list as follows:
|
||||
|
||||
The entered value may contain a visible KST suffix or portable components. KST4Contest normalises it to the base callsign before storing it. Different variants of the same base callsign are therefore treated as duplicates.
|
||||
|
||||
Changes to the list take effect immediately. Press **Save Settings** afterwards to retain them. The base callsigns are stored in `preferences.xml` and restored at the next program start.
|
||||
Changes to the list take effect as soon as they are applied. Press **Save Settings** afterwards to retain them. The base callsigns are stored in `preferences.xml` and restored at the next program start.
|
||||
|
||||
> Base-callsign monitoring across KST suffixes is included from v1.42 onwards.
|
||||
|
||||
@@ -929,7 +931,7 @@ Two optional additions to the band columns can be enabled or disabled in the **G
|
||||
- **Show "o" in band columns …** displays an `o` if the four-character grid square has already been worked on the relevant band. Disabling the option does not delete any database records; it only hides the additional indicator in the band columns. `wkdany` is unaffected.
|
||||
- **Show "a" in band columns …** distinguishes a completely new callsign from a band opportunity involving a callsign already worked elsewhere. When disabled, both cases are displayed as `B+`. The band-opportunity calculation itself remains unchanged.
|
||||
|
||||
Changes are reflected in the current user interface immediately. Click **Save Settings** afterwards if they should persist after the next start.
|
||||
Applied changes are reflected in the current user interface at once. Click **Save Settings** afterwards if they should persist after the next start.
|
||||
|
||||

|
||||
|
||||
@@ -1003,7 +1005,7 @@ The **Profiles** tab in the settings window lists all profiles with their name,
|
||||
|
||||
### Switching profiles while running
|
||||
|
||||
**File > Switch operator profile...**, or the button on the **Profiles** tab, switches without restarting the program. After a confirmation the ON4KST connection is closed and the user interface is rebuilt with the settings and layout of the selected profile. The layout of the previous profile is saved first; settings not yet confirmed with **Save Settings** are lost.
|
||||
**File > Switch operator profile...**, or the button on the **Profiles** tab, switches without restarting the program. After a confirmation the ON4KST connection is closed and the user interface is rebuilt with the settings and layout of the selected profile. If the settings window still holds changes that were not applied, KST4Contest first asks whether to apply them to the current profile, discard them, or cancel the switch. The layout of the previous profile is saved first; settings not yet confirmed with **Save Settings** are lost.
|
||||
|
||||
Once more than one profile exists, the main window title also shows the profile name.
|
||||
|
||||
@@ -1015,7 +1017,7 @@ As before, the ON4KST password is stored in clear text in the `preferences.xml`
|
||||
|
||||
## Dark Mode (from v1.26)
|
||||
|
||||
Each operator profile sets its own startup colour scheme: on the **GUI** tab under **Design**, choose **Light mode** or **Dark mode**. The choice takes effect immediately and is stored in the profile's `preferences.xml` with **Save Settings**.
|
||||
Each operator profile sets its own startup colour scheme: on the **GUI** tab under **Design**, choose **Light mode** or **Dark mode**. The choice takes effect when it is applied and is stored in the profile's `preferences.xml` with **Save Settings**.
|
||||
|
||||
For a quick change during operation, use **Windows → Use dark mode design** and **Windows → Use default mode design**. This switch only lasts until the next start or profile switch; after that the setting on the **GUI** tab applies again.
|
||||
|
||||
@@ -1023,7 +1025,7 @@ For a quick change during operation, use **Windows → Use dark mode design** an
|
||||
|
||||
## Saving Settings
|
||||
|
||||
**Save Settings** stores functional settings and the complete current layout. Changes to window sizes and positions, relevant dividers, managed table-column widths and the **Group nearby stations** map setting are also saved automatically after a short delay. Any pending layout update is written when the programme exits.
|
||||
**Save Settings** applies pending changes first, then stores functional settings and the complete current layout. Changes to window sizes and positions, relevant dividers, managed table-column widths and the **Group nearby stations** map setting are also saved automatically after a short delay. Any pending layout update is written when the programme exits.
|
||||
|
||||
- Storage location: `~/.praktiKST/preferences.xml` on Linux and macOS and `%USERPROFILE%\.praktiKST\preferences.xml` (or `C:\Users\<Username>\.praktiKST\preferences.xml`) on Windows
|
||||
- The automatic layout writer does not copy functional changes which have not yet been confirmed with **Save Settings**.
|
||||
|
||||
Reference in new issue
Block a user