Skip to content

Commit eb9f941

Browse files
CopilotbedaHovorka
andauthored
refactor: decouple SimulationController from Swing components
Agent-Logs-Url: https://github.com/bedaHovorka/interlockSim/sessions/01419796-a691-46e1-ae10-8c1373a9ee18 Co-authored-by: bedaHovorka <5263405+bedaHovorka@users.noreply.github.com>
1 parent 041222a commit eb9f941

3 files changed

Lines changed: 131 additions & 76 deletions

File tree

desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/gui/Frame.kt

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,29 @@ class Frame : JFrame(PROGRAM_FULL_NAME) {
113113
}
114114

115115
// Simulation lifecycle delegated to SimulationController for testability (Issue #189)
116-
internal val simulationController: SimulationController = SimulationController(controlPanel, toolBar, statusBar)
116+
internal val simulationController: SimulationController =
117+
SimulationController(
118+
onStateChanged = { state ->
119+
runOnEdt {
120+
when (state) {
121+
SimulationController.SimulationStatus.RUNNING -> {
122+
toolBar.showSimulationControls()
123+
controlPanel.updateStatus(ControlPanel.SimulationStatus.RUNNING)
124+
controlPanel.setStopEnabled(true)
125+
}
126+
127+
SimulationController.SimulationStatus.STOPPED -> {
128+
toolBar.hideSimulationControls()
129+
controlPanel.setStopEnabled(false)
130+
controlPanel.updateStatus(ControlPanel.SimulationStatus.STOPPED)
131+
}
132+
}
133+
}
134+
},
135+
onSpeedChanged = { speed ->
136+
runOnEdt { statusBar.updateSpeedIndicator(speed) }
137+
}
138+
)
117139
private var currentSimulationContext: SimulationContext? = null
118140

119141
/**
@@ -397,6 +419,14 @@ class Frame : JFrame(PROGRAM_FULL_NAME) {
397419
}
398420
}
399421

422+
private fun runOnEdt(action: () -> Unit) {
423+
if (javax.swing.SwingUtilities.isEventDispatchThread()) {
424+
action()
425+
} else {
426+
javax.swing.SwingUtilities.invokeLater(action)
427+
}
428+
}
429+
400430
/**
401431
* Handles window closing event.
402432
* Shows confirmation dialog if there are unsaved changes.

desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/gui/SimulationController.kt

Lines changed: 37 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,8 @@
1111
package cz.vutbr.fit.interlockSim.gui
1212

1313
import cz.vutbr.fit.interlockSim.context.SimulationContext
14-
import cz.vutbr.fit.interlockSim.gui.animation.ControlPanel
1514
import io.github.oshai.kotlinlogging.KotlinLogging
1615
import java.beans.PropertyChangeListener
17-
import javax.swing.SwingUtilities
1816

1917
/**
2018
* Manages the simulation lifecycle on behalf of [Frame] (Issue #189).
@@ -27,30 +25,26 @@ import javax.swing.SwingUtilities
2725
* - Starting the runner synchronously (before the monitor thread) to prevent the
2826
* race condition where [stop] is called before the monitor thread starts the runner
2927
* - Polling for completion on a daemon "SimulationMonitor" thread
30-
* - Enabling/disabling the Stop button in [ControlPanel] as the lifecycle changes
31-
* - Dispatching [onCompleted] back to EDT when the simulation finishes naturally
28+
* - Reporting lifecycle and speed changes via callbacks
3229
*
3330
* ## Thread Safety
34-
* - [start] and [stop] are designed to be called from the same thread (typically EDT
35-
* in production, but also from test threads in unit tests). They are NOT thread-safe
36-
* for concurrent calls from different threads; external callers are responsible for
37-
* serialization. [Frame] enforces EDT-only access via its own `require()` guards.
31+
* - [start] and [stop] are designed to be called from the same thread. They are NOT
32+
* thread-safe for concurrent calls from different threads; external callers are
33+
* responsible for serialization.
3834
* - [runner] is `@Volatile` so the monitor thread reads a fresh value when [stop]
3935
* nulls it.
40-
* - [onCompleted] is always dispatched to EDT via [SwingUtilities.invokeLater].
36+
* - Callbacks are invoked on whichever thread performs the lifecycle change.
4137
*
42-
* @param controlPanel ControlPanel whose Stop button and status label are managed here.
43-
* @param toolBar Optional [ToolBar] to show/hide simulation controls on lifecycle changes.
44-
* @param statusBar Optional [StatusBar] to display speed indicator when speed != 1.0x.
45-
* @param onCompleted Callback invoked on EDT when the simulation finishes naturally.
46-
* Defaults to a no-op if not provided.
38+
* @param onStateChanged Callback for lifecycle state updates.
39+
* @param onSpeedChanged Callback for speed indicator updates.
40+
* @param onCompleted Callback invoked when the simulation finishes naturally.
41+
* Defaults to a no-op.
4742
* @since 2026-04-20 (extracted from Frame for testability)
4843
* @see Frame
4944
*/
5045
internal class SimulationController(
51-
private val controlPanel: ControlPanel,
52-
private val toolBar: ToolBar? = null,
53-
private val statusBar: StatusBar? = null,
46+
private val onStateChanged: (SimulationStatus) -> Unit = {},
47+
private val onSpeedChanged: (Double) -> Unit = {},
5448
private val onCompleted: () -> Unit = {},
5549
) {
5650
/**
@@ -82,9 +76,9 @@ internal class SimulationController(
8276
* 2. Calls [SimulationRunner.start] **synchronously** (before the monitor thread) to
8377
* eliminate the race condition where [stop] could be invoked before the monitor
8478
* thread has a chance to start the runner.
85-
* 3. Updates [ControlPanel] status to "Running" and enables the Stop button.
79+
* 3. Emits [SimulationStatus.RUNNING] via [onStateChanged].
8680
* 4. Launches a daemon "SimulationMonitor" thread that polls [SimulationRunner.isRunning]
87-
* and on completion dispatches [onCompleted] and resets the panel via EDT.
81+
* and on completion emits [SimulationStatus.STOPPED] and invokes [onCompleted].
8882
*
8983
* @param context The simulation context to run.
9084
*/
@@ -103,35 +97,30 @@ internal class SimulationController(
10397
// thread. This ensures stopSimulation() always has a live thread to interrupt.
10498
newRunner.start()
10599

106-
// Wire speed indicator: notify StatusBar whenever SimulationRunner speed changes.
100+
// Wire speed callback for SimulationRunner speed changes.
107101
// The listener is removed when the simulation stops (in stop() or monitor finally).
108102
val listener = PropertyChangeListener { evt ->
109103
val multiplier = evt.newValue as? Double
110104
if (multiplier == null) {
111105
logger.debug { "Ignoring unexpected ${SimulationRunner.PROP_SPEED_MULTIPLIER} value: ${evt.newValue}" }
112106
return@PropertyChangeListener
113107
}
114-
statusBar?.updateSpeedIndicator(multiplier)
108+
onSpeedChanged(multiplier)
115109
}
116110
speedListener = listener
117111
newRunner.addPropertyChangeListener(SimulationRunner.PROP_SPEED_MULTIPLIER, listener)
118112

119-
// Show simulation controls in ToolBar (EDT-safe: start() is called from EDT).
120-
toolBar?.showSimulationControls()
121-
122-
controlPanel.updateStatus(ControlPanel.SimulationStatus.RUNNING)
123-
controlPanel.setStopEnabled(true)
113+
onStateChanged(SimulationStatus.RUNNING)
124114

125115
launchMonitorThread(newRunner)
126116
}
127117

128118
/**
129119
* Launch a daemon "SimulationMonitor" thread that polls [newRunner] for completion
130-
* and dispatches panel reset and [onCompleted] to EDT when done.
120+
* and dispatches callback notifications when done.
131121
*
132-
* Guards against stale-monitor: the [SwingUtilities.invokeLater] callback checks
133-
* `runner === newRunner` before mutating state so that a stop+start cycle started
134-
* before the lambda fires cannot clobber the new run's panel state.
122+
* Guards against stale-monitor by checking `runner === newRunner` before mutating
123+
* state so that a stop+start cycle cannot clobber the new run's state.
135124
*/
136125
private fun launchMonitorThread(newRunner: SimulationRunner) {
137126
val monitorThread =
@@ -144,20 +133,15 @@ internal class SimulationController(
144133
} catch (e: InterruptedException) {
145134
Thread.currentThread().interrupt()
146135
} finally {
147-
SwingUtilities.invokeLater {
148-
// Guard against stale-monitor: if stop() + start(ctxB) ran on EDT
149-
// before this callback fired, runner has been replaced with a new
150-
// instance. Skip the reset to avoid clobbering the new run's panel
151-
// state (and avoid firing onCompleted for the old run).
152-
if (runner === newRunner) {
153-
cleanupSpeedListener(newRunner)
154-
runner = null
155-
toolBar?.hideSimulationControls()
156-
statusBar?.updateSpeedIndicator(SimulationRunner.DEFAULT_SPEED)
157-
controlPanel.updateStatus(ControlPanel.SimulationStatus.STOPPED)
158-
controlPanel.setStopEnabled(false)
159-
onCompleted()
160-
}
136+
// Guard against stale-monitor: if stop() + start(ctxB) ran before
137+
// this callback executes, runner has been replaced with a new instance.
138+
// Skip reset to avoid clobbering the new run's state.
139+
if (runner === newRunner) {
140+
cleanupSpeedListener(newRunner)
141+
runner = null
142+
onSpeedChanged(SimulationRunner.DEFAULT_SPEED)
143+
onStateChanged(SimulationStatus.STOPPED)
144+
onCompleted()
161145
}
162146
}
163147
},
@@ -168,7 +152,7 @@ internal class SimulationController(
168152
}
169153

170154
/**
171-
* Stop a running simulation and reset the [ControlPanel].
155+
* Stop a running simulation and emit [SimulationStatus.STOPPED].
172156
*
173157
* Safe to call when no simulation is running (no-op in that case).
174158
*/
@@ -177,10 +161,8 @@ internal class SimulationController(
177161
cleanupSpeedListener(r)
178162
r.stop()
179163
runner = null
180-
toolBar?.hideSimulationControls()
181-
statusBar?.updateSpeedIndicator(SimulationRunner.DEFAULT_SPEED)
182-
controlPanel.setStopEnabled(false)
183-
controlPanel.updateStatus(ControlPanel.SimulationStatus.STOPPED)
164+
onSpeedChanged(SimulationRunner.DEFAULT_SPEED)
165+
onStateChanged(SimulationStatus.STOPPED)
184166
}
185167

186168
/** Removes the speed [PropertyChangeListener] from [r] and clears the reference. */
@@ -215,4 +197,10 @@ internal class SimulationController(
215197
/** Poll interval (ms) for the monitor thread to detect simulation completion. */
216198
internal const val SIMULATION_POLL_INTERVAL_MS: Long = 500L
217199
}
200+
201+
/** Simulation lifecycle states emitted via [onStateChanged]. */
202+
enum class SimulationStatus {
203+
RUNNING,
204+
STOPPED,
205+
}
218206
}

0 commit comments

Comments
 (0)