Skip to content
46 changes: 30 additions & 16 deletions desktop-ui/src/main/kotlin/cz/vutbr/fit/interlockSim/gui/Frame.kt
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ import javax.swing.Timer
* ├─────────────────────────────────┤
* │ EventTimelinePanel (NEW) │
* │ [Filters] [Event log...] │
* ├─────────────────────────────────┤
* │ StatusBar (speed indicator) │
* └─────────────────────────────────┘
* ```
*
Expand Down Expand Up @@ -105,8 +107,13 @@ class Frame : JFrame(PROGRAM_FULL_NAME) {
private var eventTimelinePanel: cz.vutbr.fit.interlockSim.gui.animation.EventTimelinePanel? = null
private var animationUpdateTimer: Timer? = null

// South panel: always at BorderLayout.SOUTH; holds StatusBar and optionally EventTimelinePanel
private val southPanel: JPanel = JPanel().apply {
layout = BoxLayout(this, BoxLayout.Y_AXIS)
}

// Simulation lifecycle delegated to SimulationController for testability (Issue #189)
internal val simulationController: SimulationController = SimulationController(controlPanel)
internal val simulationController: SimulationController = SimulationController(controlPanel, toolBar, statusBar)
private var currentSimulationContext: SimulationContext? = null

/**
Expand Down Expand Up @@ -134,8 +141,10 @@ class Frame : JFrame(PROGRAM_FULL_NAME) {
northContainer.add(simulationControlPanel)
contentPane.add(northContainer, BorderLayout.NORTH)

// South panel contains StatusBar (edit mode) and EventTimelinePanel (simulation mode)
statusBar.registerProducer(railwayNetGridCanvas)
contentPane.add(statusBar, BorderLayout.SOUTH)
southPanel.add(statusBar)
contentPane.add(southPanel, BorderLayout.SOUTH)

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

// Hide StatusBar, show EventTimelinePanel
statusBar.isVisible = false
contentPane.remove(statusBar)
eventTimelinePanel?.let {
contentPane.add(it, BorderLayout.SOUTH)
// Add EventTimelinePanel before StatusBar (index 0 = top of south panel, above StatusBar)
eventTimelinePanel?.let { panel ->
if (panel.parent == null) {
southPanel.add(panel, TIMELINE_PANEL_SOUTH_INDEX)
}
}

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

southPanel.revalidate()
southPanel.repaint()
contentPane.revalidate()
contentPane.repaint()
}

/**
* Switch UI layout to editing mode (Issue #205).
*
* - Shows StatusBar
* - Hides EventTimelinePanel
* - Removes EventTimelinePanel from south panel (StatusBar remains visible throughout)
* - Hides ControlPanel
* - Enables editing ToolBar
*
Expand All @@ -206,12 +217,10 @@ class Frame : JFrame(PROGRAM_FULL_NAME) {
"switchToEditingMode must be called from EDT"
}

// Show StatusBar, hide EventTimelinePanel
eventTimelinePanel?.let {
contentPane.remove(it)
// Remove EventTimelinePanel from south panel (StatusBar stays visible always)
eventTimelinePanel?.let { panel ->
southPanel.remove(panel)
}
contentPane.add(statusBar, BorderLayout.SOUTH)
statusBar.isVisible = true

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

southPanel.revalidate()
southPanel.repaint()
contentPane.revalidate()
contentPane.repaint()
}
Expand Down Expand Up @@ -366,6 +377,9 @@ class Frame : JFrame(PROGRAM_FULL_NAME) {

companion object {
private val logger = KotlinLogging.logger {}

/** Index at which EventTimelinePanel is inserted in [southPanel] (above StatusBar). */
private const val TIMELINE_PANEL_SOUTH_INDEX = 0
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ package cz.vutbr.fit.interlockSim.gui
import cz.vutbr.fit.interlockSim.context.SimulationContext
import cz.vutbr.fit.interlockSim.gui.animation.ControlPanel
import io.github.oshai.kotlinlogging.KotlinLogging
import java.beans.PropertyChangeListener
import javax.swing.SwingUtilities

/**
Expand All @@ -39,13 +40,17 @@ import javax.swing.SwingUtilities
* - [onCompleted] is always dispatched to EDT via [SwingUtilities.invokeLater].
*
* @param controlPanel ControlPanel whose Stop button and status label are managed here.
* @param toolBar Optional [ToolBar] to show/hide simulation controls on lifecycle changes.
* @param statusBar Optional [StatusBar] to display speed indicator when speed != 1.0x.
* @param onCompleted Callback invoked on EDT when the simulation finishes naturally.
* Defaults to a no-op if not provided.
* @since 2026-04-20 (extracted from Frame for testability)
* @see Frame
*/
internal class SimulationController(
private val controlPanel: ControlPanel,
private val toolBar: ToolBar? = null,
private val statusBar: StatusBar? = null,
private val onCompleted: () -> Unit = {},
) {
/**
Expand All @@ -57,6 +62,9 @@ internal class SimulationController(
var runner: SimulationRunner? = null
private set

/** Listener registered on the active runner for speed changes; removed on stop. */
private var speedListener: PropertyChangeListener? = null

/**
* Desired speed multiplier applied to new and currently running simulations.
*
Expand Down Expand Up @@ -95,6 +103,22 @@ internal class SimulationController(
// thread. This ensures stopSimulation() always has a live thread to interrupt.
newRunner.start()

// Wire speed indicator: notify StatusBar whenever SimulationRunner speed changes.
// The listener is removed when the simulation stops (in stop() or monitor finally).
val listener = PropertyChangeListener { evt ->
val multiplier = evt.newValue as? Double
if (multiplier == null) {
logger.debug { "Ignoring unexpected ${SimulationRunner.PROP_SPEED_MULTIPLIER} value: ${evt.newValue}" }
return@PropertyChangeListener
}
statusBar?.updateSpeedIndicator(multiplier)
Comment thread
bedaHovorka marked this conversation as resolved.
Comment thread
bedaHovorka marked this conversation as resolved.
}
speedListener = listener
newRunner.addPropertyChangeListener(SimulationRunner.PROP_SPEED_MULTIPLIER, listener)

// Show simulation controls in ToolBar (EDT-safe: start() is called from EDT).
toolBar?.showSimulationControls()

controlPanel.updateStatus(ControlPanel.SimulationStatus.RUNNING)
controlPanel.setStopEnabled(true)

Expand Down Expand Up @@ -126,7 +150,10 @@ internal class SimulationController(
// instance. Skip the reset to avoid clobbering the new run's panel
// state (and avoid firing onCompleted for the old run).
if (runner === newRunner) {
cleanupSpeedListener(newRunner)
runner = null
toolBar?.hideSimulationControls()
statusBar?.updateSpeedIndicator(SimulationRunner.DEFAULT_SPEED)
controlPanel.updateStatus(ControlPanel.SimulationStatus.STOPPED)
controlPanel.setStopEnabled(false)
onCompleted()
Expand All @@ -147,12 +174,21 @@ internal class SimulationController(
*/
fun stop() {
val r = runner ?: return
cleanupSpeedListener(r)
r.stop()
runner = null
toolBar?.hideSimulationControls()
statusBar?.updateSpeedIndicator(SimulationRunner.DEFAULT_SPEED)
controlPanel.setStopEnabled(false)
controlPanel.updateStatus(ControlPanel.SimulationStatus.STOPPED)
}

/** Removes the speed [PropertyChangeListener] from [r] and clears the reference. */
private fun cleanupSpeedListener(r: SimulationRunner) {
speedListener?.let { r.removePropertyChangeListener(SimulationRunner.PROP_SPEED_MULTIPLIER, it) }
speedListener = null
}

/** Returns `true` while the underlying [SimulationRunner] reports running. */
fun isRunning(): Boolean = runner?.isRunning() ?: false

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,34 @@ import cz.vutbr.fit.interlockSim.PROGRAM_NAME
import cz.vutbr.fit.interlockSim.context.ContextChangeListener
import cz.vutbr.fit.interlockSim.exceptions.requireValidState
import cz.vutbr.fit.interlockSim.objects.core.ContextChangeEvent
import java.awt.BorderLayout
import java.awt.Component
import java.awt.Dimension
import java.awt.event.MouseEvent
import java.awt.event.MouseMotionListener
import java.util.Locale
import javax.swing.JLabel
import javax.swing.JPanel
import javax.swing.SwingUtilities
import kotlin.math.abs

/**
* Status bar for displaying context information and mouse motion status
* Status bar for displaying context information and mouse motion status.
*
* Implemented as a [JPanel] containing two labels:
* - [statusLabel] (CENTER): shows context property-change messages and mouse-position info.
* - [speedLabel] (EAST): shows the current simulation speed multiplier when it differs from
* 1.0x; hidden at default speed. Written only from EDT via [updateSpeedIndicator].
*
* Separating the two labels avoids the conflict where simulation-thread property-change
* callbacks (via [ContextChangeListener]) would otherwise overwrite the speed indicator text.
*/
class StatusBar :
JLabel(),
JPanel(),
ContextChangeListener {
private val statusLabel = JLabel()
private val speedLabel = JLabel().apply { isVisible = false }

private val mouseListener =
object : MouseMotionListener {
override fun mouseDragged(e: MouseEvent) {
Expand All @@ -39,7 +55,17 @@ class StatusBar :
}
}

/** Delegates to [statusLabel], providing a backward-compatible text property. */
var text: String
get() = statusLabel.text ?: ""
set(value) {
statusLabel.text = value
}

init {
layout = BorderLayout()
add(statusLabel, BorderLayout.CENTER)
add(speedLabel, BorderLayout.EAST)
preferredSize = Dimension(100, 25)
text = "Welcome to " + PROGRAM_NAME
}
Expand Down Expand Up @@ -89,4 +115,46 @@ class StatusBar :
timer.isRepeats = false
timer.start()
}

/**
* Updates the speed indicator in [speedLabel] (separate from [statusLabel]).
*
* When [multiplier] differs from 1.0x, [speedLabel] shows "Speed: X.Xx" and becomes
* visible. At default speed (1.0x) the label is hidden and its text is cleared so
* no stale speed string is shown if the status bar later becomes visible again.
*
* This method is EDT-safe: it executes synchronously when already on the EDT (so
* [SimulationController.stop] on the EDT takes effect before subsequent mode-switch
* calls), and uses [SwingUtilities.invokeLater] when called from a background thread.
*
* @param multiplier Current speed multiplier from [SimulationRunner]
*/
fun updateSpeedIndicator(multiplier: Double) {
if (SwingUtilities.isEventDispatchThread()) {
applySpeedIndicator(multiplier)
} else {
SwingUtilities.invokeLater { applySpeedIndicator(multiplier) }
}
Comment thread
bedaHovorka marked this conversation as resolved.
}

private fun applySpeedIndicator(multiplier: Double) {
if (abs(multiplier - DEFAULT_SPEED) > SPEED_EPSILON) {
speedLabel.text = String.format(Locale.ROOT, "Speed: %.1fx", multiplier)
speedLabel.isVisible = true
} else {
speedLabel.text = ""
speedLabel.isVisible = false
}
}

/** Returns `true` when the speed indicator label is currently visible. */
internal fun isSpeedIndicatorVisible(): Boolean = speedLabel.isVisible

/** Returns the current speed indicator text, or an empty string when hidden. */
internal fun speedIndicatorText(): String = speedLabel.text ?: ""

companion object {
private const val DEFAULT_SPEED = 1.0
private const val SPEED_EPSILON = 0.001
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import java.awt.Dimension
import java.awt.event.ActionEvent
import javax.swing.AbstractAction
import javax.swing.ButtonGroup
import javax.swing.JLabel
import javax.swing.JToggleButton
import javax.swing.JToolBar

Expand Down Expand Up @@ -121,4 +122,37 @@ class ToolBar : JToolBar() {
component.isEnabled = enabled
}
}

private val simControlsSeparator: JToolBar.Separator = JToolBar.Separator()
private val simControlsLabel: JLabel = JLabel("▶ Simulation")

/**
* Shows the simulation controls panel in the toolbar.
*
* Called when a simulation starts to indicate simulation mode visually.
* Idempotent: safe to call if controls are already showing.
* Must be called from the Event Dispatch Thread (EDT).
*/
fun showSimulationControls() {
if (simControlsLabel.parent != null) return
add(simControlsSeparator)
add(simControlsLabel)
revalidate()
repaint()
}

/**
* Hides the simulation controls panel from the toolbar.
*
* Called when a simulation stops to return to editing mode appearance.
* Idempotent: safe to call if controls are not currently showing.
* Must be called from the Event Dispatch Thread (EDT).
*/
fun hideSimulationControls() {
if (simControlsLabel.parent == null) return
remove(simControlsSeparator)
remove(simControlsLabel)
revalidate()
repaint()
Comment thread
bedaHovorka marked this conversation as resolved.
}
}
Loading
Loading