mirror of
https://github.com/praktimarc/kst4contest.git
synced 2026-10-20 06:12:57 +02:00
* 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>
248 lines
9.7 KiB
Kotlin
248 lines
9.7 KiB
Kotlin
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 }
|