Skip to content

Commit 3f30b97

Browse files
CopilotbedaHovorkaclaude
authored
Phase 2.2: ToolBar and StatusBar Integration for Simulation Speed Control (#485)
* 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> * Fix code review: safe cast, kotlin.math.abs import, function name typo, named constant Agent-Logs-Url: https://github.com/bedaHovorka/interlockSim/sessions/a05d505b-27f4-4dc3-8ad6-c336a6ca4ec8 Co-authored-by: bedaHovorka <5263405+bedaHovorka@users.noreply.github.com> * Address code review: import order, debug logging, flushEDT() helpers in tests Agent-Logs-Url: https://github.com/bedaHovorka/interlockSim/sessions/a05d505b-27f4-4dc3-8ad6-c336a6ca4ec8 Co-authored-by: bedaHovorka <5263405+bedaHovorka@users.noreply.github.com> * Changes before error encountered Agent-Logs-Url: https://github.com/bedaHovorka/interlockSim/sessions/f4316065-c3a6-47a4-be98-31cf8f27dd5d Co-authored-by: bedaHovorka <5263405+bedaHovorka@users.noreply.github.com> * Fix Frame.kt simulation-mode layout diagram to show StatusBar The class-level ASCII diagram omitted StatusBar from the simulation mode view. The implementation keeps StatusBar visible throughout (speed indicator uses its separate speedLabel); the diagram now matches. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix flaky stopHidesToolBarSimulationControls: add missing flushEDT() The test was missing the EDT drain call that the sibling test stopResetsStatusBarSpeedIndicator already has. Without it, the monitor thread's invokeLater callback races the invokeAndWait verify task on slow CI runners, causing a non-deterministic 10s timeout. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Bump stopHidesToolBarSimulationControls @timeout 10s -> 30s CI runs on ubuntu-latest with no Xvfb, default headless mode, and parallel test forks. The cumulative latch + flushEDT + invokeAndWait budget exceeds the previous 10s cap under contention even though the test logic is sound. 30s gives ~3x headroom while still failing fast on a true logic regression (internal latches still cap themselves at 5s and 10s). Co-Authored-By: Claude Opus 4.7 (1M context) <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 fefac01 commit 3f30b97

8 files changed

Lines changed: 469 additions & 25 deletions

File tree

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

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ import javax.swing.Timer
6666
* ├─────────────────────────────────┤
6767
* │ EventTimelinePanel (NEW) │
6868
* │ [Filters] [Event log...] │
69+
* ├─────────────────────────────────┤
70+
* │ StatusBar (speed indicator) │
6971
* └─────────────────────────────────┘
7072
* ```
7173
*
@@ -105,8 +107,13 @@ class Frame : JFrame(PROGRAM_FULL_NAME) {
105107
private var eventTimelinePanel: cz.vutbr.fit.interlockSim.gui.animation.EventTimelinePanel? = null
106108
private var animationUpdateTimer: Timer? = null
107109

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

112119
/**
@@ -134,8 +141,10 @@ class Frame : JFrame(PROGRAM_FULL_NAME) {
134141
northContainer.add(simulationControlPanel)
135142
contentPane.add(northContainer, BorderLayout.NORTH)
136143

144+
// South panel contains StatusBar (edit mode) and EventTimelinePanel (simulation mode)
137145
statusBar.registerProducer(railwayNetGridCanvas)
138-
contentPane.add(statusBar, BorderLayout.SOUTH)
146+
southPanel.add(statusBar)
147+
contentPane.add(southPanel, BorderLayout.SOUTH)
139148

140149
// Add component listener to refresh canvas when frame is resized
141150
addComponentListener(
@@ -160,8 +169,9 @@ class Frame : JFrame(PROGRAM_FULL_NAME) {
160169
/**
161170
* Switch UI layout to simulation mode (Issue #205).
162171
*
163-
* - Hides StatusBar
164-
* - Shows EventTimelinePanel (if created)
172+
* - StatusBar remains visible (its speed indicator [StatusBar.updateSpeedIndicator] shows
173+
* non-default speeds; [StatusBar.statusLabel] continues to display simulation events)
174+
* - Adds EventTimelinePanel to south panel (if created)
165175
* - Shows ControlPanel
166176
* - Disables editing ToolBar
167177
*
@@ -172,11 +182,11 @@ class Frame : JFrame(PROGRAM_FULL_NAME) {
172182
"switchToSimulationMode must be called from EDT"
173183
}
174184

175-
// Hide StatusBar, show EventTimelinePanel
176-
statusBar.isVisible = false
177-
contentPane.remove(statusBar)
178-
eventTimelinePanel?.let {
179-
contentPane.add(it, BorderLayout.SOUTH)
185+
// Add EventTimelinePanel before StatusBar (index 0 = top of south panel, above StatusBar)
186+
eventTimelinePanel?.let { panel ->
187+
if (panel.parent == null) {
188+
southPanel.add(panel, TIMELINE_PANEL_SOUTH_INDEX)
189+
}
180190
}
181191

182192
// Show ControlPanel and SimulationControlPanel
@@ -187,15 +197,16 @@ class Frame : JFrame(PROGRAM_FULL_NAME) {
187197
// Disable editing toolbar in simulation mode
188198
toolBar.setToolsEnabled(false)
189199

200+
southPanel.revalidate()
201+
southPanel.repaint()
190202
contentPane.revalidate()
191203
contentPane.repaint()
192204
}
193205

194206
/**
195207
* Switch UI layout to editing mode (Issue #205).
196208
*
197-
* - Shows StatusBar
198-
* - Hides EventTimelinePanel
209+
* - Removes EventTimelinePanel from south panel (StatusBar remains visible throughout)
199210
* - Hides ControlPanel
200211
* - Enables editing ToolBar
201212
*
@@ -206,12 +217,10 @@ class Frame : JFrame(PROGRAM_FULL_NAME) {
206217
"switchToEditingMode must be called from EDT"
207218
}
208219

209-
// Show StatusBar, hide EventTimelinePanel
210-
eventTimelinePanel?.let {
211-
contentPane.remove(it)
220+
// Remove EventTimelinePanel from south panel (StatusBar stays visible always)
221+
eventTimelinePanel?.let { panel ->
222+
southPanel.remove(panel)
212223
}
213-
contentPane.add(statusBar, BorderLayout.SOUTH)
214-
statusBar.isVisible = true
215224

216225
// Hide ControlPanel and SimulationControlPanel
217226
controlPanel.isVisible = false
@@ -220,6 +229,8 @@ class Frame : JFrame(PROGRAM_FULL_NAME) {
220229
// Enable editing toolbar in editing mode
221230
toolBar.setToolsEnabled(true)
222231

232+
southPanel.revalidate()
233+
southPanel.repaint()
223234
contentPane.revalidate()
224235
contentPane.repaint()
225236
}
@@ -366,6 +377,9 @@ class Frame : JFrame(PROGRAM_FULL_NAME) {
366377

367378
companion object {
368379
private val logger = KotlinLogging.logger {}
380+
381+
/** Index at which EventTimelinePanel is inserted in [southPanel] (above StatusBar). */
382+
private const val TIMELINE_PANEL_SOUTH_INDEX = 0
369383
}
370384

371385
/**

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

Lines changed: 36 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
* Desired speed multiplier applied to new and currently running simulations.
6270
*
@@ -95,6 +103,22 @@ internal class SimulationController(
95103
// thread. This ensures stopSimulation() always has a live thread to interrupt.
96104
newRunner.start()
97105

106+
// Wire speed indicator: notify StatusBar whenever SimulationRunner speed changes.
107+
// The listener is removed when the simulation stops (in stop() or monitor finally).
108+
val listener = PropertyChangeListener { evt ->
109+
val multiplier = evt.newValue as? Double
110+
if (multiplier == null) {
111+
logger.debug { "Ignoring unexpected ${SimulationRunner.PROP_SPEED_MULTIPLIER} value: ${evt.newValue}" }
112+
return@PropertyChangeListener
113+
}
114+
statusBar?.updateSpeedIndicator(multiplier)
115+
}
116+
speedListener = listener
117+
newRunner.addPropertyChangeListener(SimulationRunner.PROP_SPEED_MULTIPLIER, listener)
118+
119+
// Show simulation controls in ToolBar (EDT-safe: start() is called from EDT).
120+
toolBar?.showSimulationControls()
121+
98122
controlPanel.updateStatus(ControlPanel.SimulationStatus.RUNNING)
99123
controlPanel.setStopEnabled(true)
100124

@@ -126,7 +150,10 @@ internal class SimulationController(
126150
// instance. Skip the reset to avoid clobbering the new run's panel
127151
// state (and avoid firing onCompleted for the old run).
128152
if (runner === newRunner) {
153+
cleanupSpeedListener(newRunner)
129154
runner = null
155+
toolBar?.hideSimulationControls()
156+
statusBar?.updateSpeedIndicator(SimulationRunner.DEFAULT_SPEED)
130157
controlPanel.updateStatus(ControlPanel.SimulationStatus.STOPPED)
131158
controlPanel.setStopEnabled(false)
132159
onCompleted()
@@ -147,12 +174,21 @@ internal class SimulationController(
147174
*/
148175
fun stop() {
149176
val r = runner ?: return
177+
cleanupSpeedListener(r)
150178
r.stop()
151179
runner = null
180+
toolBar?.hideSimulationControls()
181+
statusBar?.updateSpeedIndicator(SimulationRunner.DEFAULT_SPEED)
152182
controlPanel.setStopEnabled(false)
153183
controlPanel.updateStatus(ControlPanel.SimulationStatus.STOPPED)
154184
}
155185

186+
/** Removes the speed [PropertyChangeListener] from [r] and clears the reference. */
187+
private fun cleanupSpeedListener(r: SimulationRunner) {
188+
speedListener?.let { r.removePropertyChangeListener(SimulationRunner.PROP_SPEED_MULTIPLIER, it) }
189+
speedListener = null
190+
}
191+
156192
/** Returns `true` while the underlying [SimulationRunner] reports running. */
157193
fun isRunning(): Boolean = runner?.isRunning() ?: false
158194

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

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,34 @@ import cz.vutbr.fit.interlockSim.PROGRAM_NAME
1313
import cz.vutbr.fit.interlockSim.context.ContextChangeListener
1414
import cz.vutbr.fit.interlockSim.exceptions.requireValidState
1515
import cz.vutbr.fit.interlockSim.objects.core.ContextChangeEvent
16+
import java.awt.BorderLayout
1617
import java.awt.Component
1718
import java.awt.Dimension
1819
import java.awt.event.MouseEvent
1920
import java.awt.event.MouseMotionListener
21+
import java.util.Locale
2022
import javax.swing.JLabel
23+
import javax.swing.JPanel
24+
import javax.swing.SwingUtilities
25+
import kotlin.math.abs
2126

2227
/**
23-
* Status bar for displaying context information and mouse motion status
28+
* Status bar for displaying context information and mouse motion status.
29+
*
30+
* Implemented as a [JPanel] containing two labels:
31+
* - [statusLabel] (CENTER): shows context property-change messages and mouse-position info.
32+
* - [speedLabel] (EAST): shows the current simulation speed multiplier when it differs from
33+
* 1.0x; hidden at default speed. Written only from EDT via [updateSpeedIndicator].
34+
*
35+
* Separating the two labels avoids the conflict where simulation-thread property-change
36+
* callbacks (via [ContextChangeListener]) would otherwise overwrite the speed indicator text.
2437
*/
2538
class StatusBar :
26-
JLabel(),
39+
JPanel(),
2740
ContextChangeListener {
41+
private val statusLabel = JLabel()
42+
private val speedLabel = JLabel().apply { isVisible = false }
43+
2844
private val mouseListener =
2945
object : MouseMotionListener {
3046
override fun mouseDragged(e: MouseEvent) {
@@ -39,7 +55,17 @@ class StatusBar :
3955
}
4056
}
4157

58+
/** Delegates to [statusLabel], providing a backward-compatible text property. */
59+
var text: String
60+
get() = statusLabel.text ?: ""
61+
set(value) {
62+
statusLabel.text = value
63+
}
64+
4265
init {
66+
layout = BorderLayout()
67+
add(statusLabel, BorderLayout.CENTER)
68+
add(speedLabel, BorderLayout.EAST)
4369
preferredSize = Dimension(100, 25)
4470
text = "Welcome to " + PROGRAM_NAME
4571
}
@@ -89,4 +115,46 @@ class StatusBar :
89115
timer.isRepeats = false
90116
timer.start()
91117
}
118+
119+
/**
120+
* Updates the speed indicator in [speedLabel] (separate from [statusLabel]).
121+
*
122+
* When [multiplier] differs from 1.0x, [speedLabel] shows "Speed: X.Xx" and becomes
123+
* visible. At default speed (1.0x) the label is hidden and its text is cleared so
124+
* no stale speed string is shown if the status bar later becomes visible again.
125+
*
126+
* This method is EDT-safe: it executes synchronously when already on the EDT (so
127+
* [SimulationController.stop] on the EDT takes effect before subsequent mode-switch
128+
* calls), and uses [SwingUtilities.invokeLater] when called from a background thread.
129+
*
130+
* @param multiplier Current speed multiplier from [SimulationRunner]
131+
*/
132+
fun updateSpeedIndicator(multiplier: Double) {
133+
if (SwingUtilities.isEventDispatchThread()) {
134+
applySpeedIndicator(multiplier)
135+
} else {
136+
SwingUtilities.invokeLater { applySpeedIndicator(multiplier) }
137+
}
138+
}
139+
140+
private fun applySpeedIndicator(multiplier: Double) {
141+
if (abs(multiplier - DEFAULT_SPEED) > SPEED_EPSILON) {
142+
speedLabel.text = String.format(Locale.ROOT, "Speed: %.1fx", multiplier)
143+
speedLabel.isVisible = true
144+
} else {
145+
speedLabel.text = ""
146+
speedLabel.isVisible = false
147+
}
148+
}
149+
150+
/** Returns `true` when the speed indicator label is currently visible. */
151+
internal fun isSpeedIndicatorVisible(): Boolean = speedLabel.isVisible
152+
153+
/** Returns the current speed indicator text, or an empty string when hidden. */
154+
internal fun speedIndicatorText(): String = speedLabel.text ?: ""
155+
156+
companion object {
157+
private const val DEFAULT_SPEED = 1.0
158+
private const val SPEED_EPSILON = 0.001
159+
}
92160
}

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
}

0 commit comments

Comments
 (0)