Skip to content

Commit 9f2991b

Browse files
CopilotbedaHovorka
andauthored
Phase 2.2: Add ToolBar/StatusBar simulation controls and speed indicator integration
Agent-Logs-Url: https://github.com/bedaHovorka/interlockSim/sessions/a05d505b-27f4-4dc3-8ad6-c336a6ca4ec8 Co-authored-by: bedaHovorka <5263405+bedaHovorka@users.noreply.github.com>
1 parent ab0fefa commit 9f2991b

7 files changed

Lines changed: 240 additions & 14 deletions

File tree

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

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,13 @@ class Frame : JFrame(PROGRAM_FULL_NAME) {
104104
private var eventTimelinePanel: cz.vutbr.fit.interlockSim.gui.animation.EventTimelinePanel? = null
105105
private var animationUpdateTimer: Timer? = null
106106

107+
// South panel: always at BorderLayout.SOUTH; holds StatusBar and optionally EventTimelinePanel
108+
private val southPanel: JPanel = JPanel().apply {
109+
layout = BoxLayout(this, BoxLayout.Y_AXIS)
110+
}
111+
107112
// Simulation lifecycle delegated to SimulationController for testability (Issue #189)
108-
internal val simulationController: SimulationController = SimulationController(controlPanel)
113+
internal val simulationController: SimulationController = SimulationController(controlPanel, toolBar, statusBar)
109114
private var currentSimulationContext: SimulationContext? = null
110115

111116
/**
@@ -131,8 +136,10 @@ class Frame : JFrame(PROGRAM_FULL_NAME) {
131136
northContainer.add(controlPanel)
132137
contentPane.add(northContainer, BorderLayout.NORTH)
133138

139+
// South panel contains StatusBar (edit mode) and EventTimelinePanel (simulation mode)
134140
statusBar.registerProducer(railwayNetGridCanvas)
135-
contentPane.add(statusBar, BorderLayout.SOUTH)
141+
southPanel.add(statusBar)
142+
contentPane.add(southPanel, BorderLayout.SOUTH)
136143

137144
// Add component listener to refresh canvas when frame is resized
138145
addComponentListener(
@@ -157,8 +164,8 @@ class Frame : JFrame(PROGRAM_FULL_NAME) {
157164
/**
158165
* Switch UI layout to simulation mode (Issue #205).
159166
*
160-
* - Hides StatusBar
161-
* - Shows EventTimelinePanel (if created)
167+
* - Hides StatusBar (shown by speed indicator when speed != 1.0x)
168+
* - Shows EventTimelinePanel in south panel (if created)
162169
* - Shows ControlPanel
163170
* - Disables editing ToolBar
164171
*
@@ -169,11 +176,13 @@ class Frame : JFrame(PROGRAM_FULL_NAME) {
169176
"switchToSimulationMode must be called from EDT"
170177
}
171178

172-
// Hide StatusBar, show EventTimelinePanel
179+
// Hide StatusBar; speed indicator (updateSpeedIndicator) will show it when speed != 1.0x
173180
statusBar.isVisible = false
174-
contentPane.remove(statusBar)
175-
eventTimelinePanel?.let {
176-
contentPane.add(it, BorderLayout.SOUTH)
181+
// Add EventTimelinePanel to south panel
182+
eventTimelinePanel?.let { panel ->
183+
if (panel.parent == null) {
184+
southPanel.add(panel, 0)
185+
}
177186
}
178187

179188
// Show ControlPanel
@@ -183,6 +192,8 @@ class Frame : JFrame(PROGRAM_FULL_NAME) {
183192
// Disable editing toolbar in simulation mode
184193
toolBar.setToolsEnabled(false)
185194

195+
southPanel.revalidate()
196+
southPanel.repaint()
186197
contentPane.revalidate()
187198
contentPane.repaint()
188199
}
@@ -202,11 +213,10 @@ class Frame : JFrame(PROGRAM_FULL_NAME) {
202213
"switchToEditingMode must be called from EDT"
203214
}
204215

205-
// Show StatusBar, hide EventTimelinePanel
206-
eventTimelinePanel?.let {
207-
contentPane.remove(it)
216+
// Remove EventTimelinePanel from south panel; show StatusBar
217+
eventTimelinePanel?.let { panel ->
218+
southPanel.remove(panel)
208219
}
209-
contentPane.add(statusBar, BorderLayout.SOUTH)
210220
statusBar.isVisible = true
211221

212222
// Hide ControlPanel
@@ -215,6 +225,8 @@ class Frame : JFrame(PROGRAM_FULL_NAME) {
215225
// Enable editing toolbar in editing mode
216226
toolBar.setToolsEnabled(true)
217227

228+
southPanel.revalidate()
229+
southPanel.repaint()
218230
contentPane.revalidate()
219231
contentPane.repaint()
220232
}

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ package cz.vutbr.fit.interlockSim.gui
1313
import cz.vutbr.fit.interlockSim.context.SimulationContext
1414
import cz.vutbr.fit.interlockSim.gui.animation.ControlPanel
1515
import io.github.oshai.kotlinlogging.KotlinLogging
16+
import java.beans.PropertyChangeListener
1617
import javax.swing.SwingUtilities
1718

1819
/**
@@ -39,13 +40,17 @@ import javax.swing.SwingUtilities
3940
* - [onCompleted] is always dispatched to EDT via [SwingUtilities.invokeLater].
4041
*
4142
* @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.
4245
* @param onCompleted Callback invoked on EDT when the simulation finishes naturally.
4346
* Defaults to a no-op if not provided.
4447
* @since 2026-04-20 (extracted from Frame for testability)
4548
* @see Frame
4649
*/
4750
internal class SimulationController(
4851
private val controlPanel: ControlPanel,
52+
private val toolBar: ToolBar? = null,
53+
private val statusBar: StatusBar? = null,
4954
private val onCompleted: () -> Unit = {},
5055
) {
5156
/**
@@ -57,6 +62,9 @@ internal class SimulationController(
5762
var runner: SimulationRunner? = null
5863
private set
5964

65+
/** Listener registered on the active runner for speed changes; removed on stop. */
66+
private var speedListener: PropertyChangeListener? = null
67+
6068
/**
6169
* Start the simulation for [context].
6270
*
@@ -87,6 +95,17 @@ internal class SimulationController(
8795
// thread. This ensures stopSimulation() always has a live thread to interrupt.
8896
newRunner.start()
8997

98+
// Wire speed indicator: notify StatusBar whenever SimulationRunner speed changes.
99+
// The listener is removed when the simulation stops (in stop() or monitor finally).
100+
val listener = PropertyChangeListener { evt ->
101+
statusBar?.updateSpeedIndicator(evt.newValue as Double)
102+
}
103+
speedListener = listener
104+
newRunner.addPropertyChangeListener(SimulationRunner.PROP_SPEED_MULTIPLIER, listener)
105+
106+
// Show simulation controls in ToolBar (EDT-safe: start() is called from EDT).
107+
toolBar?.showSimulationControls()
108+
90109
controlPanel.updateStatus(ControlPanel.SimulationStatus.RUNNING)
91110
controlPanel.setStopEnabled(true)
92111

@@ -118,7 +137,10 @@ internal class SimulationController(
118137
// instance. Skip the reset to avoid clobbering the new run's panel
119138
// state (and avoid firing onCompleted for the old run).
120139
if (runner === newRunner) {
140+
cleanupSpeedListener(newRunner)
121141
runner = null
142+
toolBar?.hideSimulationControls()
143+
statusBar?.updateSpeedIndicator(SimulationRunner.DEFAULT_SPEED)
122144
controlPanel.updateStatus(ControlPanel.SimulationStatus.STOPPED)
123145
controlPanel.setStopEnabled(false)
124146
onCompleted()
@@ -139,12 +161,21 @@ internal class SimulationController(
139161
*/
140162
fun stop() {
141163
val r = runner ?: return
164+
cleanupSpeedListener(r)
142165
r.stop()
143166
runner = null
167+
toolBar?.hideSimulationControls()
168+
statusBar?.updateSpeedIndicator(SimulationRunner.DEFAULT_SPEED)
144169
controlPanel.setStopEnabled(false)
145170
controlPanel.updateStatus(ControlPanel.SimulationStatus.STOPPED)
146171
}
147172

173+
/** Removes the speed [PropertyChangeListener] from [r] and clears the reference. */
174+
private fun cleanupSpeedListener(r: SimulationRunner) {
175+
speedListener?.let { r.removePropertyChangeListener(SimulationRunner.PROP_SPEED_MULTIPLIER, it) }
176+
speedListener = null
177+
}
178+
148179
/** Returns `true` while the underlying [SimulationRunner] reports running. */
149180
fun isRunning(): Boolean = runner?.isRunning() ?: false
150181

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import java.awt.Dimension
1818
import java.awt.event.MouseEvent
1919
import java.awt.event.MouseMotionListener
2020
import javax.swing.JLabel
21+
import javax.swing.SwingUtilities
2122

2223
/**
2324
* Status bar for displaying context information and mouse motion status
@@ -89,4 +90,30 @@ class StatusBar :
8990
timer.isRepeats = false
9091
timer.start()
9192
}
93+
94+
/**
95+
* Updates the speed indicator display.
96+
*
97+
* Shows speed information in the status bar when the multiplier differs from
98+
* real-time (1.0x). Hides the status bar when running at default speed.
99+
*
100+
* EDT-safe: uses [SwingUtilities.invokeLater] if called from a background thread.
101+
*
102+
* @param multiplier Current speed multiplier from [cz.vutbr.fit.interlockSim.gui.SimulationRunner]
103+
*/
104+
fun updateSpeedIndicator(multiplier: Double) {
105+
SwingUtilities.invokeLater {
106+
if (kotlin.math.abs(multiplier - DEFAULT_SPEED) > SPEED_EPSILON) {
107+
text = "Speed: ${"%.1f".format(multiplier)}x"
108+
isVisible = true
109+
} else {
110+
isVisible = false
111+
}
112+
}
113+
}
114+
115+
companion object {
116+
private const val DEFAULT_SPEED = 1.0
117+
private const val SPEED_EPSILON = 0.001
118+
}
92119
}

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import java.awt.Dimension
2323
import java.awt.event.ActionEvent
2424
import javax.swing.AbstractAction
2525
import javax.swing.ButtonGroup
26+
import javax.swing.JLabel
2627
import javax.swing.JToggleButton
2728
import javax.swing.JToolBar
2829

@@ -121,4 +122,37 @@ class ToolBar : JToolBar() {
121122
component.isEnabled = enabled
122123
}
123124
}
125+
126+
private val simControlsSeparator: JToolBar.Separator = JToolBar.Separator()
127+
private val simControlsLabel: JLabel = JLabel("▶ Simulation")
128+
129+
/**
130+
* Shows the simulation controls panel in the toolbar.
131+
*
132+
* Called when a simulation starts to indicate simulation mode visually.
133+
* Idempotent: safe to call if controls are already showing.
134+
* Must be called from the Event Dispatch Thread (EDT).
135+
*/
136+
fun showSimulationControls() {
137+
if (simControlsLabel.parent != null) return
138+
add(simControlsSeparator)
139+
add(simControlsLabel)
140+
revalidate()
141+
repaint()
142+
}
143+
144+
/**
145+
* Hides the simulation controls panel from the toolbar.
146+
*
147+
* Called when a simulation stops to return to editing mode appearance.
148+
* Idempotent: safe to call if controls are not currently showing.
149+
* Must be called from the Event Dispatch Thread (EDT).
150+
*/
151+
fun hideSimulationControls() {
152+
if (simControlsLabel.parent == null) return
153+
remove(simControlsSeparator)
154+
remove(simControlsLabel)
155+
revalidate()
156+
repaint()
157+
}
124158
}

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import assertk.assertThat
1515
import assertk.assertions.isEqualTo
1616
import assertk.assertions.isInstanceOf
1717
import assertk.assertions.isNotNull
18+
import assertk.assertions.isTrue
1819
import cz.vutbr.fit.interlockSim.PROGRAM_FULL_NAME
1920
import cz.vutbr.fit.interlockSim.context.EditingContext
2021
import cz.vutbr.fit.interlockSim.context.EditingContextFactory
@@ -146,12 +147,16 @@ class FrameTest : AbstractFrameTestBase() {
146147

147148
@Test
148149
@Timeout(value = 5, unit = TimeUnit.SECONDS)
149-
@DisplayName("frame has status bar at south")
150+
@DisplayName("frame has status bar in south panel")
150151
fun frameHasStatusBarAtSouth() {
151152
runOnEDT {
153+
// SOUTH now contains a southPanel (JPanel) that wraps StatusBar and EventTimelinePanel
152154
val southComponent = (frame.contentPane.layout as BorderLayout).getLayoutComponent(BorderLayout.SOUTH)
153155
assertThat(southComponent).isNotNull()
154-
assertThat(southComponent).isInstanceOf(StatusBar::class)
156+
assertThat(southComponent).isInstanceOf(javax.swing.JPanel::class)
157+
// StatusBar must be accessible and correctly initialised
158+
assertThat(frame.statusBar).isInstanceOf(StatusBar::class)
159+
assertThat(frame.statusBar.isVisible).isTrue()
155160
}
156161
}
157162

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

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,80 @@ class SimulationControllerTest {
445445

446446
// ── helpers ───────────────────────────────────────────────────────────────
447447

448+
// ── toolBar / statusBar wiring ────────────────────────────────────────────
449+
450+
@Test
451+
@Timeout(value = 10, unit = TimeUnit.SECONDS)
452+
@DisplayName("speed change on runner propagates to StatusBar indicator")
453+
fun speedChangePropagatestoStatusBar() {
454+
val started = CountDownLatch(1)
455+
val blockSim = CountDownLatch(1)
456+
every { context.run() } answers {
457+
started.countDown()
458+
blockSim.await(10, TimeUnit.SECONDS)
459+
}
460+
461+
val statusBar = StatusBar()
462+
463+
val controller = SimulationController(controlPanel, statusBar = statusBar)
464+
controller.start(context)
465+
assertThat(started.await(5, TimeUnit.SECONDS)).isTrue()
466+
467+
// Change speed via runner — this fires PROP_SPEED_MULTIPLIER
468+
controller.runner!!.speedMultiplier = 2.0
469+
470+
// Flush EDT twice (listener calls invokeLater, so two flushes are needed)
471+
SwingUtilities.invokeAndWait { /* flush 1 */ }
472+
SwingUtilities.invokeAndWait { /* flush 2 */ }
473+
474+
SwingUtilities.invokeAndWait {
475+
assertThat(findSpeedText(statusBar)).isEqualTo("Speed: 2.0x")
476+
assertThat(statusBar.isVisible).isTrue()
477+
}
478+
479+
blockSim.countDown()
480+
controller.stop()
481+
}
482+
483+
@Test
484+
@Timeout(value = 10, unit = TimeUnit.SECONDS)
485+
@DisplayName("stop resets StatusBar speed indicator to hidden")
486+
fun stopResetsStatusBarSpeedIndicator() {
487+
val started = CountDownLatch(1)
488+
val blockSim = CountDownLatch(1)
489+
every { context.run() } answers {
490+
started.countDown()
491+
blockSim.await(10, TimeUnit.SECONDS)
492+
}
493+
494+
val statusBar = StatusBar()
495+
496+
val controller = SimulationController(controlPanel, statusBar = statusBar)
497+
controller.start(context)
498+
assertThat(started.await(5, TimeUnit.SECONDS)).isTrue()
499+
500+
// Set non-default speed, then stop
501+
controller.runner!!.speedMultiplier = 3.0
502+
SwingUtilities.invokeAndWait { /* flush */ }
503+
SwingUtilities.invokeAndWait { /* flush invokeLater */ }
504+
505+
controller.stop()
506+
blockSim.countDown()
507+
508+
// Stop calls updateSpeedIndicator(DEFAULT_SPEED) via invokeLater
509+
SwingUtilities.invokeAndWait { /* flush stop's invokeLater */ }
510+
SwingUtilities.invokeAndWait { /* flush nested */ }
511+
512+
SwingUtilities.invokeAndWait {
513+
assertThat(statusBar.isVisible).isFalse()
514+
}
515+
}
516+
517+
// ── helpers ───────────────────────────────────────────────────────────────
518+
519+
private fun findSpeedText(statusBar: StatusBar): String? =
520+
statusBar.text.takeIf { it.startsWith("Speed:") }
521+
448522
private fun findStopButton(): JButton? =
449523
(0 until controlPanel.componentCount)
450524
.mapNotNull { controlPanel.getComponent(it) as? JButton }

0 commit comments

Comments
 (0)