Skip to content

Commit b23bcb1

Browse files
CopilotbedaHovorka
andauthored
Add simulation menu with speed control (#486)
* Add Simulation menu with Start/Stop/Speed control to MenuBar Agent-Logs-Url: https://github.com/bedaHovorka/interlockSim/sessions/69dde112-6219-41c1-8517-555ceb9dcb6e Co-authored-by: bedaHovorka <5263405+bedaHovorka@users.noreply.github.com> * Apply reviewer feedback: fix resource leak, enable report types, clear modificationTracker, add setSpeed tests Agent-Logs-Url: https://github.com/bedaHovorka/interlockSim/sessions/d9498f97-4781-4986-98c1-96cb2a57850d Co-authored-by: bedaHovorka <5263405+bedaHovorka@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: bedaHovorka <5263405+bedaHovorka@users.noreply.github.com>
1 parent 10f4518 commit b23bcb1

4 files changed

Lines changed: 384 additions & 9 deletions

File tree

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

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,20 @@ package cz.vutbr.fit.interlockSim.gui
1212

1313
import cz.vutbr.fit.interlockSim.context.EditingContext
1414
import cz.vutbr.fit.interlockSim.context.JvmEditingContextFactory
15+
import cz.vutbr.fit.interlockSim.context.SimulationContext
16+
import cz.vutbr.fit.interlockSim.context.SimulationContextFactory
1517
import cz.vutbr.fit.interlockSim.xml.XMLContextFactory
1618
import org.koin.mp.KoinPlatform.getKoin
1719
import java.awt.event.ActionEvent
20+
import java.awt.event.KeyEvent
1821
import java.io.File
1922
import javax.swing.AbstractAction
2023
import javax.swing.JFileChooser
2124
import javax.swing.JMenu
2225
import javax.swing.JMenuBar
26+
import javax.swing.JMenuItem
2327
import javax.swing.JOptionPane
28+
import javax.swing.KeyStroke
2429

2530
/**
2631
* Application menu bar with File and Help menus
@@ -265,6 +270,90 @@ class MenuBar : JMenuBar() {
265270
}
266271
}
267272

273+
/**
274+
* Shows a file chooser, loads the selected XML as a [SimulationContext], sets it on the
275+
* [Frame] and immediately starts the simulation.
276+
*
277+
* **Resource management:** The intermediate [EditingContext] created by
278+
* [JvmEditingContextFactory.createContext] is wrapped in `use {}` to ensure its Koin
279+
* scope is closed after the [SimulationContext] transformation, preventing a resource
280+
* leak of the temporary editing context.
281+
*
282+
* **Report types:** All report types are enabled on the [SimulationContext] before
283+
* passing it to [Frame.setContext] so that [AnimationController] and
284+
* [cz.vutbr.fit.interlockSim.gui.animation.EventTimelinePanel] receive property-change
285+
* events and the animation is not visually frozen.
286+
*
287+
* **Modification tracker:** The tracker is cleared before switching to simulation mode
288+
* so that the "unsaved changes" path in [Frame.handleWindowClosing] does not attempt to
289+
* save a [SimulationContext] through the editor's save logic.
290+
*/
291+
private inner class StartSimulationAction : AbstractAction("Start...") {
292+
override fun actionPerformed(e: ActionEvent) {
293+
val fileChooser = JFileChooser(System.getProperty("user.dir"))
294+
fileChooser.dialogTitle = "Start Simulation"
295+
296+
val returnValue = fileChooser.showOpenDialog(this@MenuBar)
297+
if (returnValue != JFileChooser.APPROVE_OPTION) return
298+
299+
val selectedFile: File = fileChooser.selectedFile
300+
301+
try {
302+
val editingContextFactory = getKoin().get<JvmEditingContextFactory>()
303+
val simulationContextFactory = getKoin().get<SimulationContextFactory>()
304+
305+
// Wrap intermediate EditingContext in use{} to close its Koin scope after
306+
// transformation, avoiding a resource leak.
307+
val simContext =
308+
editingContextFactory.createContext(selectedFile).use { editCtx ->
309+
simulationContextFactory.createContext(editCtx as EditingContext)
310+
}
311+
312+
// Enable all report types so AnimationController and EventTimelinePanel receive
313+
// property-change events (without this the animation stays visually frozen).
314+
simContext.addReportTypes(*SimulationContext.ReportType.values())
315+
316+
val frame = getKoin().get<Frame>()
317+
318+
// Clear dirty flag before switching to simulation mode. If the user had unsaved
319+
// edits, continuing in simulation mode implicitly discards them; the window-close
320+
// handler must not try to save a non-existent EditingContext afterwards.
321+
frame.modificationTracker.markClean()
322+
frame.modificationTracker.setCurrentFile(null)
323+
324+
frame.setContext(simContext)
325+
frame.startSimulation()
326+
} catch (exception: Exception) {
327+
JOptionPane.showMessageDialog(
328+
this@MenuBar,
329+
"Failed to start simulation: ${exception.message}\n\n" +
330+
"Ensure the file is a valid railway network XML.",
331+
"Cannot Start Simulation",
332+
JOptionPane.ERROR_MESSAGE
333+
)
334+
}
335+
}
336+
}
337+
338+
/** Terminates the currently running simulation via [Frame.stopSimulation]. */
339+
private inner class StopSimulationAction : AbstractAction("Stop") {
340+
override fun actionPerformed(e: ActionEvent) {
341+
val frame = getKoin().get<Frame>()
342+
frame.stopSimulation()
343+
}
344+
}
345+
346+
/** Sets the simulation speed multiplier via [SimulationController.setSpeed]. */
347+
private inner class SetSpeedAction(
348+
private val label: String,
349+
private val multiplier: Double,
350+
) : AbstractAction(label) {
351+
override fun actionPerformed(e: ActionEvent) {
352+
val frame = getKoin().get<Frame>()
353+
frame.simulationController.setSpeed(multiplier)
354+
}
355+
}
356+
268357
private inner class InfoAction(
269358
private val infoName: String,
270359
private val text: String
@@ -276,6 +365,7 @@ class MenuBar : JMenuBar() {
276365

277366
init {
278367
add(fileMenu())
368+
add(simulationMenu())
279369
add(helpMenu())
280370
}
281371

@@ -289,6 +379,37 @@ class MenuBar : JMenuBar() {
289379
return menu
290380
}
291381

382+
/**
383+
* Builds the "Simulation" menu with Start/Stop actions and a Speed submenu.
384+
*
385+
* Speed presets (0.1x, 0.5x, 1x, 2x, 10x) have keyboard accelerators (keys 1–5)
386+
* so that the user can change the speed without reaching for the mouse during a run.
387+
*/
388+
private fun simulationMenu(): JMenu {
389+
val menu = JMenu("Simulation")
390+
menu.add(StartSimulationAction())
391+
menu.add(StopSimulationAction())
392+
menu.addSeparator()
393+
394+
val speedMenu = JMenu("Speed")
395+
val speedPresets =
396+
listOf(
397+
Triple("0.1x", 0.1, KeyEvent.VK_1),
398+
Triple("0.5x", 0.5, KeyEvent.VK_2),
399+
Triple("1x", 1.0, KeyEvent.VK_3),
400+
Triple("2x", 2.0, KeyEvent.VK_4),
401+
Triple("10x", 10.0, KeyEvent.VK_5),
402+
)
403+
for ((label, multiplier, keyCode) in speedPresets) {
404+
val item = JMenuItem(SetSpeedAction(label, multiplier))
405+
item.accelerator = KeyStroke.getKeyStroke(keyCode, 0)
406+
speedMenu.add(item)
407+
}
408+
menu.add(speedMenu)
409+
410+
return menu
411+
}
412+
292413
private fun helpMenu(): JMenu {
293414
val menu = JMenu("Help")
294415
menu.add(
@@ -300,7 +421,16 @@ class MenuBar : JMenuBar() {
300421
"<br><b>Editing:</b><br>" +
301422
"- Left mouse: Insert nodes and join them<br>" +
302423
"- Middle mouse: Delete nodes<br>" +
303-
"- Right mouse: Popup menu</html>"
424+
"- Right mouse: Popup menu<br>" +
425+
"<br><b>Simulation:</b><br>" +
426+
"- Simulation &gt; Start...: Load XML and start simulation<br>" +
427+
"- Simulation &gt; Stop: Terminate running simulation<br>" +
428+
"<br><b>Simulation Speed (keyboard shortcuts):</b><br>" +
429+
"- Key 1: 0.1x speed<br>" +
430+
"- Key 2: 0.5x speed<br>" +
431+
"- Key 3: 1x speed (real-time)<br>" +
432+
"- Key 4: 2x speed<br>" +
433+
"- Key 5: 10x speed</html>"
304434
)
305435
)
306436
menu.add(

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,13 @@ internal class SimulationController(
5757
var runner: SimulationRunner? = null
5858
private set
5959

60+
/**
61+
* Desired speed multiplier applied to new and currently running simulations.
62+
*
63+
* Stored so that a speed selection before [start] is honoured once the runner is created.
64+
*/
65+
private var desiredSpeed: Double = SimulationRunner.DEFAULT_SPEED
66+
6067
/**
6168
* Start the simulation for [context].
6269
*
@@ -81,6 +88,7 @@ internal class SimulationController(
8188
}
8289

8390
val newRunner = SimulationRunner(context)
91+
newRunner.speedMultiplier = desiredSpeed
8492
runner = newRunner
8593

8694
// Start synchronously BEFORE enabling the Stop button or launching the monitor
@@ -148,6 +156,23 @@ internal class SimulationController(
148156
/** Returns `true` while the underlying [SimulationRunner] reports running. */
149157
fun isRunning(): Boolean = runner?.isRunning() ?: false
150158

159+
/**
160+
* Set the simulation speed multiplier.
161+
*
162+
* Applied immediately to the currently running simulation (if any) and stored
163+
* so it is also honoured by the next [start] call.
164+
*
165+
* @param multiplier Speed factor in [SimulationRunner.MIN_SPEED]..[SimulationRunner.MAX_SPEED].
166+
* @throws IllegalArgumentException if [multiplier] is outside the valid range.
167+
*/
168+
fun setSpeed(multiplier: Double) {
169+
require(multiplier in SimulationRunner.MIN_SPEED..SimulationRunner.MAX_SPEED) {
170+
"speedMultiplier must be in [${SimulationRunner.MIN_SPEED}..${SimulationRunner.MAX_SPEED}], got: $multiplier"
171+
}
172+
desiredSpeed = multiplier
173+
runner?.speedMultiplier = multiplier
174+
}
175+
151176
companion object {
152177
private val logger = KotlinLogging.logger {}
153178

0 commit comments

Comments
 (0)