Skip to content

Commit 10f4518

Browse files
CopilotbedaHovorkaclaude
authored
Add SimulationControlPanel component with speed control features (#484)
* Add SimulationControlPanel with speed control features (Issue #190) - SimulationControlPanel.kt: linear JSlider (0.1x-10x), 7 preset buttons (0.1x, 0.5x, 1x, 2x, 5x, 10x, 50x), live speed label, PropertyChangeListener wiring to SimulationRunner - SimulationControlPanelTest.kt: 22 unit tests covering slider/button behavior, runner wiring, listener removal on runner replacement - Frame.kt: wire SimulationControlPanel into northContainer, show/hide with simulation mode, connect to runner on startSimulation/stopSimulation Agent-Logs-Url: https://github.com/bedaHovorka/interlockSim/sessions/e9d0a748-c45e-4190-a083-8d04d829b60e Co-authored-by: bedaHovorka <5263405+bedaHovorka@users.noreply.github.com> * Fix applyPreset: remove unnecessary clamping, set runner speed directly Agent-Logs-Url: https://github.com/bedaHovorka/interlockSim/sessions/e9d0a748-c45e-4190-a083-8d04d829b60e Co-authored-by: bedaHovorka <5263405+bedaHovorka@users.noreply.github.com> * Address PR review comments on SimulationControlPanel - Fix speedLabel comment: shows "1.0x" not "Speed: 1.0x" - runner setter: retain current UI when cleared to null (don't reset to DEFAULT_SPEED) - runnerListener: call syncUiToSpeed synchronously when already on EDT - speedToSlider: use Math.round() instead of toInt() for consistent rounding Agent-Logs-Url: https://github.com/bedaHovorka/interlockSim/sessions/2c9b4cff-1da6-42d2-803d-6c964ac92150 Co-authored-by: bedaHovorka <5263405+bedaHovorka@users.noreply.github.com> * Fix FrameSimulationLifecycleTest: fire run-listeners directly in MockSimulationContext.run() ContextTransformer.createSimulationContext() freezes the delegate before returning, so delegate.freeze() in run() hit the if (!frozen) guard and was a no-op — no PropertyChangeEvent ever fired and the 3 FrameSimulationLifecycleTest latches timed out. Override addPropertyChangeListener/removePropertyChangeListener to maintain a shadow runListeners list. run() now fires ContextChangeEvent("frozen", false, true) from runListeners directly, bypassing the frozen-state guard. Listeners are still also delegated to the underlying DefaultSimulationContext so Frame's statusBar listener continues to receive real property-change events. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix FrameTest: update north container component count assertion to 3 SimulationControlPanel (Issue #190) was added as a third child of the north JPanel container, but the test still expected 2 components. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: bedaHovorka <5263405+bedaHovorka@users.noreply.github.com> Co-authored-by: Bedrich Hovorka <bedrich.hovorka@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent b27b470 commit 10f4518

4 files changed

Lines changed: 570 additions & 5 deletions

File tree

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ class Frame : JFrame(PROGRAM_FULL_NAME) {
101101

102102
// Animation UI components (Issue #205)
103103
private val controlPanel: ControlPanel = ControlPanel()
104+
internal val simulationControlPanel: SimulationControlPanel = SimulationControlPanel()
104105
private var eventTimelinePanel: cz.vutbr.fit.interlockSim.gui.animation.EventTimelinePanel? = null
105106
private var animationUpdateTimer: Timer? = null
106107

@@ -129,6 +130,8 @@ class Frame : JFrame(PROGRAM_FULL_NAME) {
129130
northContainer.add(toolBar)
130131
controlPanel.isVisible = false // Initially hidden (shown only in simulation mode)
131132
northContainer.add(controlPanel)
133+
simulationControlPanel.isVisible = false // Initially hidden (shown only in simulation mode)
134+
northContainer.add(simulationControlPanel)
132135
contentPane.add(northContainer, BorderLayout.NORTH)
133136

134137
statusBar.registerProducer(railwayNetGridCanvas)
@@ -176,9 +179,10 @@ class Frame : JFrame(PROGRAM_FULL_NAME) {
176179
contentPane.add(it, BorderLayout.SOUTH)
177180
}
178181

179-
// Show ControlPanel
182+
// Show ControlPanel and SimulationControlPanel
180183
controlPanel.isVisible = true
181184
controlPanel.updateStatus(ControlPanel.SimulationStatus.READY)
185+
simulationControlPanel.isVisible = true
182186

183187
// Disable editing toolbar in simulation mode
184188
toolBar.setToolsEnabled(false)
@@ -209,8 +213,9 @@ class Frame : JFrame(PROGRAM_FULL_NAME) {
209213
contentPane.add(statusBar, BorderLayout.SOUTH)
210214
statusBar.isVisible = true
211215

212-
// Hide ControlPanel
216+
// Hide ControlPanel and SimulationControlPanel
213217
controlPanel.isVisible = false
218+
simulationControlPanel.isVisible = false
214219

215220
// Enable editing toolbar in editing mode
216221
toolBar.setToolsEnabled(true)
@@ -338,6 +343,8 @@ class Frame : JFrame(PROGRAM_FULL_NAME) {
338343
}
339344

340345
simulationController.start(context)
346+
// Wire SimulationControlPanel to the new runner for speed control
347+
simulationControlPanel.runner = simulationController.runner
341348
}
342349

343350
/**
@@ -353,6 +360,8 @@ class Frame : JFrame(PROGRAM_FULL_NAME) {
353360
"stopSimulation must be called from EDT"
354361
}
355362
simulationController.stop()
363+
// Detach SimulationControlPanel from runner when simulation stops
364+
simulationControlPanel.runner = null
356365
}
357366

358367
companion object {
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
/* Brno University of Technology
2+
* Faculty of Information Technology
3+
*
4+
* BSc Thesis 2006/2007
5+
*
6+
* Railway Interlocking Simulator
7+
*
8+
* Bedrich Hovorka
9+
*/
10+
package cz.vutbr.fit.interlockSim.gui
11+
12+
import java.awt.FlowLayout
13+
import java.beans.PropertyChangeEvent
14+
import java.beans.PropertyChangeListener
15+
import javax.swing.BorderFactory
16+
import javax.swing.BoxLayout
17+
import javax.swing.JButton
18+
import javax.swing.JLabel
19+
import javax.swing.JPanel
20+
import javax.swing.JSlider
21+
import javax.swing.SwingConstants
22+
import javax.swing.SwingUtilities
23+
24+
/**
25+
* Speed control panel for the simulation, implementing Phase 2.1 of Goal 7 (Issue #190).
26+
*
27+
* Provides:
28+
* - A linear [JSlider] covering 0.1× to 10× in 0.1× increments
29+
* - Seven preset buttons: 0.1×, 0.5×, 1×, 2×, 5×, 10×, 50×
30+
* - A live speed label showing the current multiplier
31+
*
32+
* The slider range is 0.1×–10× (expert users reach 50× via the preset button only).
33+
* All slider integer values are mapped to `value / SLIDER_SCALE` so that the internal
34+
* int range [1..100] maps to double range [0.1..10.0].
35+
*
36+
* **PropertyChangeListener integration:**
37+
* - Setting [runner] installs a listener on [SimulationRunner.PROP_SPEED_MULTIPLIER] so
38+
* that speed changes made programmatically (e.g. from tests) are reflected in the UI.
39+
* - User interaction (slider drag, button click) writes back to [SimulationRunner.speedMultiplier].
40+
* - The panel is automatically hidden/shown by [cz.vutbr.fit.interlockSim.gui.Frame] when
41+
* switching between editing and simulation modes.
42+
*
43+
* **Thread Safety:**
44+
* All methods must be called from the Event Dispatch Thread (EDT).
45+
*
46+
* @since 2026-05-05 (Phase 2.1, Issue #190)
47+
* @see SimulationRunner
48+
* @see cz.vutbr.fit.interlockSim.gui.Frame
49+
*/
50+
class SimulationControlPanel : JPanel() {
51+
52+
/** Scale factor: slider int value → speed double (1 → 0.1, 100 → 10.0). */
53+
private val slider: JSlider
54+
55+
/** Label showing the current speed multiplier (e.g. "1.0x"). */
56+
private val speedLabel: JLabel
57+
58+
/**
59+
* The [SimulationRunner] currently wired to this panel, or `null` when no
60+
* simulation is running. Setting this property:
61+
* - Removes the listener from the old runner (if any)
62+
* - Installs a listener on the new runner (if non-null) for [SimulationRunner.PROP_SPEED_MULTIPLIER]
63+
* - Synchronises the slider and label to the new runner's current speed (when non-null);
64+
* setting to `null` retains the last displayed speed so the panel is not visually reset.
65+
*
66+
* Must be set from the EDT.
67+
*/
68+
var runner: SimulationRunner? = null
69+
set(value) {
70+
field?.removePropertyChangeListener(SimulationRunner.PROP_SPEED_MULTIPLIER, runnerListener)
71+
field = value
72+
value?.addPropertyChangeListener(SimulationRunner.PROP_SPEED_MULTIPLIER, runnerListener)
73+
if (value != null) {
74+
syncUiToSpeed(value.speedMultiplier)
75+
}
76+
// When value is null, keep the current UI state so the speed display is not reset.
77+
}
78+
79+
/** Listener that keeps the UI in sync when the runner's speed changes externally. */
80+
private val runnerListener = PropertyChangeListener { evt: PropertyChangeEvent ->
81+
val speed = evt.newValue as? Double ?: return@PropertyChangeListener
82+
if (SwingUtilities.isEventDispatchThread()) {
83+
syncUiToSpeed(speed)
84+
} else {
85+
SwingUtilities.invokeLater { syncUiToSpeed(speed) }
86+
}
87+
}
88+
89+
/** Flag to suppress recursive slider → runner → slider feedback loops. */
90+
private var updatingFromRunner = false
91+
92+
init {
93+
layout = BoxLayout(this, BoxLayout.PAGE_AXIS)
94+
border = BorderFactory.createEtchedBorder()
95+
96+
// ── Row 1: slider ──────────────────────────────────────────────────────
97+
val sliderRow = JPanel(FlowLayout(FlowLayout.LEFT, 6, 2))
98+
99+
val sliderLabel = JLabel("Speed:")
100+
sliderRow.add(sliderLabel)
101+
102+
slider = JSlider(SwingConstants.HORIZONTAL, SLIDER_MIN, SLIDER_MAX, speedToSlider(DEFAULT_SPEED))
103+
slider.majorTickSpacing = SLIDER_MAJOR_TICK
104+
slider.minorTickSpacing = SLIDER_MINOR_TICK
105+
slider.paintTicks = true
106+
slider.paintLabels = false
107+
slider.toolTipText = "Simulation speed: 0.1x – 10x"
108+
slider.addChangeListener {
109+
if (!updatingFromRunner) {
110+
val speed = sliderToSpeed(slider.value)
111+
speedLabel.text = formatSpeedLabel(speed)
112+
runner?.speedMultiplier = speed
113+
}
114+
}
115+
sliderRow.add(slider)
116+
117+
speedLabel = JLabel(formatSpeedLabel(DEFAULT_SPEED))
118+
sliderRow.add(speedLabel)
119+
120+
add(sliderRow)
121+
122+
// ── Row 2: preset buttons ─────────────────────────────────────────────
123+
val buttonRow = JPanel(FlowLayout(FlowLayout.LEFT, 4, 2))
124+
buttonRow.add(JLabel("Presets:"))
125+
PRESETS.forEach { speed ->
126+
val btn = JButton(formatPresetLabel(speed))
127+
btn.toolTipText = "Set speed to ${formatPresetLabel(speed)}"
128+
btn.addActionListener { applyPreset(speed) }
129+
buttonRow.add(btn)
130+
}
131+
132+
add(buttonRow)
133+
}
134+
135+
// ── Internal helpers ───────────────────────────────────────────────────────
136+
137+
/** Convert a slider int value to a speed double. */
138+
private fun sliderToSpeed(value: Int): Double = value / SLIDER_SCALE
139+
140+
/** Convert a speed double to a slider int (rounded to nearest tick, clamped to [SLIDER_MIN]..[SLIDER_MAX]). */
141+
private fun speedToSlider(speed: Double): Int =
142+
Math.round(speed * SLIDER_SCALE).toInt().coerceIn(SLIDER_MIN, SLIDER_MAX)
143+
144+
/** Apply a preset speed: update runner, slider, and label. */
145+
private fun applyPreset(speed: Double) {
146+
syncUiToSpeed(speed)
147+
runner?.speedMultiplier = speed
148+
}
149+
150+
/**
151+
* Synchronise both slider and label to [speed] without triggering the
152+
* slider's change listener feedback loop.
153+
*/
154+
private fun syncUiToSpeed(speed: Double) {
155+
updatingFromRunner = true
156+
try {
157+
val sliderValue = speedToSlider(speed)
158+
if (slider.value != sliderValue) {
159+
slider.value = sliderValue
160+
}
161+
speedLabel.text = formatSpeedLabel(speed)
162+
} finally {
163+
updatingFromRunner = false
164+
}
165+
}
166+
167+
private fun formatSpeedLabel(speed: Double): String = "%.1fx".format(speed)
168+
169+
private fun formatPresetLabel(speed: Double): String =
170+
if (speed >= 1.0) "%.0fx".format(speed) else "%.1fx".format(speed)
171+
172+
companion object {
173+
/** Slider integer range: [1..100] maps to speed [0.1..10.0]. */
174+
private const val SLIDER_MIN: Int = 1
175+
private const val SLIDER_MAX: Int = 100
176+
private const val SLIDER_SCALE: Double = 10.0
177+
private const val SLIDER_MAJOR_TICK: Int = 10
178+
private const val SLIDER_MINOR_TICK: Int = 5
179+
180+
/** Default speed for the panel (1.0× = real-time). */
181+
const val DEFAULT_SPEED: Double = 1.0
182+
183+
/** Seven preset speed multipliers. Values above 10× exceed the slider range. */
184+
val PRESETS: List<Double> = listOf(0.1, 0.5, 1.0, 2.0, 5.0, 10.0, 50.0)
185+
}
186+
}

desktop-ui/src/test/kotlin/cz/vutbr/fit/interlockSim/gui/FrameTest.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,14 +133,14 @@ class FrameTest : AbstractFrameTestBase() {
133133
@DisplayName("frame has toolbar container at north")
134134
fun frameHasToolbarContainerAtNorth() {
135135
runOnEDT {
136-
// North component is now a JPanel containing ToolBar + ControlPanel (Issue #205)
136+
// North component is now a JPanel containing ToolBar + ControlPanel + SimulationControlPanel (Issues #205, #190)
137137
val northComponent = (frame.contentPane.layout as BorderLayout).getLayoutComponent(BorderLayout.NORTH)
138138
assertThat(northComponent).isNotNull()
139139
assertThat(northComponent).isInstanceOf(javax.swing.JPanel::class)
140140

141-
// Verify the container has components (ToolBar and ControlPanel)
141+
// Verify the container has components (ToolBar, ControlPanel, SimulationControlPanel)
142142
val panel = northComponent as javax.swing.JPanel
143-
assertThat(panel.componentCount).isEqualTo(2)
143+
assertThat(panel.componentCount).isEqualTo(3)
144144
}
145145
}
146146

0 commit comments

Comments
 (0)