OtsSimulationControlPanel.java

package org.opentrafficsim.swing.gui;

import java.awt.Color;
import java.awt.Dimension;
import java.awt.Font;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.FocusAdapter;
import java.awt.event.FocusEvent;
import java.awt.event.KeyAdapter;
import java.awt.event.KeyEvent;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.rmi.RemoteException;
import java.text.DecimalFormat;
import java.text.NumberFormat;
import java.text.ParseException;
import java.util.ArrayList;
import java.util.Hashtable;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.OptionalInt;
import java.util.Timer;
import java.util.TimerTask;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.Icon;
import javax.swing.JButton;
import javax.swing.JFormattedTextField;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JSlider;
import javax.swing.SwingConstants;
import javax.swing.SwingUtilities;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
import javax.swing.text.DefaultFormatter;

import org.djunits.unit.TimeUnit;
import org.djunits.value.vdouble.scalar.Duration;
import org.djunits.value.vdouble.scalar.Time;
import org.djutils.event.Event;
import org.djutils.event.EventListener;
import org.djutils.exceptions.Throw;
import org.opentrafficsim.animation.data.util.IconUtil;
import org.opentrafficsim.base.OtsRuntimeException;
import org.opentrafficsim.base.logger.Logger;
import org.opentrafficsim.core.dsol.OtsSimulatorInterface;

import nl.tudelft.simulation.dsol.SimRuntimeException;
import nl.tudelft.simulation.dsol.experiment.Replication;
import nl.tudelft.simulation.dsol.formalisms.eventscheduling.Executable;
import nl.tudelft.simulation.dsol.formalisms.eventscheduling.SimEventInterface;
import nl.tudelft.simulation.dsol.simulators.DevsRealTimeAnimator;
import nl.tudelft.simulation.dsol.simulators.SimulatorInterface;

/**
 * Peter's and Wouter's improved simulation control panel.
 * <p>
 * Copyright (c) 2013-2026 Delft University of Technology, PO Box 5, 2600 AA, Delft, the Netherlands. All rights reserved. <br>
 * BSD-style license. See <a href="https://opentrafficsim.org/docs/license.html">OpenTrafficSim License</a>.
 * </p>
 * @author Alexander Verbraeck
 * @author Peter Knoppers
 * @author Wouter Schakel
 */
public class OtsSimulationControlPanel extends JPanel implements ActionListener, PropertyChangeListener, EventListener
{

    /** Serialization version UID. */
    private static final long serialVersionUID = 20150617L;

    /** Pause icon. */
    private static final Icon PAUSE_ICON = IconUtil.of("Pause24.png").get();

    /** Play icon. */
    private static final Icon PLAY_ICON = IconUtil.of("Play24.png").get();

    /** Simulator. */
    private final OtsSimulatorInterface simulator;

    /** Control buttons. */
    private final ArrayList<JButton> buttons = new ArrayList<>();

    /** Decimal separator. */
    private final String decimalSeparator;

    /** Slider for simulation speed. */
    private final JSlider speedSlider;

    /** Ratios used in each decade of the speed slider. */
    private final int[] simulationSpeedRatios;

    /** Tick values of the speed slider. */
    private final Map<Integer, Double> tickValues = new LinkedHashMap<>();

    /** Font used to display the clock and the stop time. */
    private final Font timeFont = new Font("SansSerif", Font.BOLD, 18);

    /** Clock label. */
    private final ClockLabel clockLabel;

    /** Simulate until edit field. */
    private final TimeEdit timeEdit;

    /** Current stop event to simulate until. */
    private SimEventInterface<Duration> stopAtEvent = null;

    /** Overruled buttons enabled state (for when simulation is controlled otherwise). */
    private boolean buttonsEnabled = false;

    /** Has cleanup taken place? */
    private boolean isCleanUp = false;

    /**
     * Decorate a SimpleSimulator with a different set of control buttons.
     * @param simulator the simulator
     * @param otsAnimationPanel the OTS animation panel
     * @throws RemoteException when simulator cannot be accessed for listener attachment
     */
    public OtsSimulationControlPanel(final OtsSimulatorInterface simulator, final OtsSimulationPanel otsAnimationPanel)
            throws RemoteException
    {
        this.simulator = simulator;
        this.decimalSeparator =
                "" + ((DecimalFormat) NumberFormat.getInstance()).getDecimalFormatSymbols().getDecimalSeparator();

        setLayout(new BoxLayout(this, BoxLayout.X_AXIS));

        // buttons
        add(makeButton("stepButton", "Next24.png", "Step", "Execute one event", true));
        add(makeButton("nextTimeButton", "Step24.png", "NextTime", "Execute all events scheduled for the current time", true));
        add(makeButton("runPauseButton", "Play24.png", "RunPause", "XXX", true));
        add(Box.createHorizontalStrut(5));

        // simulation speed slider
        this.simulationSpeedRatios = new int[] {1, 2, 5};
        this.speedSlider = setupSlider(0.1, 1000, 1, simulator);
        add(this.speedSlider);
        add(Box.createHorizontalStrut(5));

        /** Label with appearance control. */
        class AppearanceControlLabel extends JLabel implements AppearanceControl
        {
            /** Serialization version UID. */
            private static final long serialVersionUID = 20180207L;

            @Override
            public boolean isForeground()
            {
                return true;
            }

            @Override
            public boolean isBackground()
            {
                return true;
            }

            @Override
            public OptionalInt getFontSize()
            {
                return OptionalInt.empty();
            }

            @Override
            public String toString()
            {
                return "AppearanceControlLabel []";
            }
        }

        // clock/timeEdit (exchanged) and speed label
        JLabel speedLabel = new AppearanceControlLabel();
        speedLabel.setMinimumSize(new Dimension(85, 25));
        speedLabel.setPreferredSize(new Dimension(85, 25));
        speedLabel.setMaximumSize(new Dimension(85, 25));
        this.clockLabel = new ClockLabel(speedLabel);
        this.clockLabel.setMinimumSize(new Dimension(130, 25));
        this.clockLabel.setPreferredSize(new Dimension(130, 25));
        this.clockLabel.setMaximumSize(new Dimension(130, 25));
        this.timeEdit = new TimeEdit(new Time(0, TimeUnit.DEFAULT));
        this.timeEdit.setMinimumSize(new Dimension(130, 25));
        this.timeEdit.setPreferredSize(new Dimension(130, 25));
        this.timeEdit.setMaximumSize(new Dimension(130, 25));
        this.timeEdit.addPropertyChangeListener("value", this); // adds event at 'simulate until' time
        add(this.clockLabel);
        add(this.timeEdit);
        add(speedLabel);

        setButtonsEnabledState();
        prepareCleanup();
        this.simulator.addListener(this, Replication.END_REPLICATION_EVENT);
        this.simulator.addListener(this, SimulatorInterface.START_EVENT);
        this.simulator.addListener(this, SimulatorInterface.STOP_EVENT);
        this.simulator.addListener(this, DevsRealTimeAnimator.CHANGE_SPEED_FACTOR_EVENT);
    }

    /**
     * Create a button.
     * @param name name of the button
     * @param iconFile name of the icon file
     * @param actionCommand the action command
     * @param toolTipText the hint to show when the mouse hovers over the button
     * @param enabled true if the new button must initially be enable; false if it must initially be disabled
     * @return created button
     */
    private JButton makeButton(final String name, final String iconFile, final String actionCommand, final String toolTipText,
            final boolean enabled)
    {
        JButton result = new AppearanceControlButton(IconUtil.of(iconFile).get());
        result.setName(name);
        result.setEnabled(enabled);
        result.setActionCommand(actionCommand);
        result.setToolTipText(toolTipText);
        result.addActionListener(this);
        Dimension dimension = new Dimension(50, 30);
        result.setMinimumSize(dimension);
        result.setPreferredSize(dimension);
        result.setMaximumSize(dimension);
        this.buttons.add(result);
        return result;
    }

    /**
     * Setup the slider.
     * @param minimum the minimum value on the scale (the displayed scale may extend a little further than this value)
     * @param maximum the maximum value on the scale (the displayed scale may extend a little further than this value)
     * @param initialValue the initially selected value on the scale
     * @param sim the simulator to change the speed of
     * @return slider
     */
    private JSlider setupSlider(final double minimum, final double maximum, final double initialValue,
            final OtsSimulatorInterface sim)
    {
        Throw.when(minimum <= 0 || minimum > initialValue || initialValue > maximum || maximum > 9999,
                OtsRuntimeException.class, "Bad (combination of) minimum, maximum and initialValue; "
                        + "(restrictions: 0 < minimum <= initialValue <= maximum <= 9999)");

        Hashtable<Integer, JLabel> labels = new Hashtable<>();
        int maximumTick = -1;
        int minimumTick = 0;
        int ratioIndex = 0;
        int scale = 0;
        while (this.simulationSpeedRatios[ratioIndex] * Math.pow(10, scale) <= maximum)
        {
            maximumTick++;
            this.tickValues.put(maximumTick, this.simulationSpeedRatios[ratioIndex] * Math.pow(10, scale));
            StringBuilder text = new StringBuilder();
            text.append(this.simulationSpeedRatios[ratioIndex]);
            for (int i = 0; i < scale; i++)
            {
                text.append("0");
            }
            labels.put(maximumTick, new JLabel(text.toString().replace("000", "K")));
            ratioIndex++;
            if (ratioIndex == this.simulationSpeedRatios.length)
            {
                ratioIndex = 0;
                scale += 1;
            }
        }
        ratioIndex = this.simulationSpeedRatios.length - 1;
        scale = 1;
        while (this.simulationSpeedRatios[ratioIndex] * Math.pow(0.1, scale) >= minimum)
        {
            minimumTick--;
            this.tickValues.put(minimumTick, this.simulationSpeedRatios[ratioIndex] * Math.pow(0.1, scale));
            StringBuilder text = new StringBuilder("0").append(OtsSimulationControlPanel.this.decimalSeparator);
            for (int i = 1; i < scale; i++)
            {
                text.append("0");
            }
            text.append(this.simulationSpeedRatios[ratioIndex]);
            labels.put(minimumTick, new JLabel(text.toString()));
            ratioIndex--;
            if (ratioIndex < 0)
            {
                ratioIndex = this.simulationSpeedRatios.length - 1;
                scale += 1;
            }
        }
        JSlider slider = new JSlider(SwingConstants.HORIZONTAL, minimumTick, maximumTick + 1, 0);
        slider.setMinimumSize(new Dimension(350, 45));
        slider.setPreferredSize(new Dimension(350, 45));
        slider.setMaximumSize(new Dimension(350, 45));
        labels.put(maximumTick + 1, new JLabel("\u221E"));
        this.tickValues.put(maximumTick + 1, 1E9);
        slider.setLabelTable(labels);
        slider.setPaintLabels(true);
        slider.setPaintTicks(true);
        slider.setMajorTickSpacing(1);
        /*- Uncomment to verify the stepToFactor method.
        for (int i = slider.getMinimum(); i <= slider.getMaximum(); i++)
        {
            System.out.println("pos=" + i + " value is " + stepToFactor(i));
        }
         */

        // initial value of simulation speed
        if (sim instanceof DevsRealTimeAnimator)
        {
            @SuppressWarnings("unchecked")
            DevsRealTimeAnimator<Duration> clock = (DevsRealTimeAnimator<Duration>) sim;
            clock.setSpeedFactor(this.tickValues.get(slider.getValue()));
        }

        // adjust the simulation speed
        slider.addChangeListener(new ChangeListener()
        {
            @Override
            public void stateChanged(final ChangeEvent ce)
            {
                JSlider source = (JSlider) ce.getSource();
                if (!source.getValueIsAdjusting() && sim instanceof DevsRealTimeAnimator)
                {
                    @SuppressWarnings("unchecked")
                    DevsRealTimeAnimator<Duration> clock = (DevsRealTimeAnimator<Duration>) sim;
                    clock.setSpeedFactor(OtsSimulationControlPanel.this.tickValues.get(source.getValue()));
                }
            }
        });

        return slider;
    }

    /**
     * Prepare the clean-up; stopping the simulator when going to close, and calling {@link #cleanup} when closed.
     */
    private void prepareCleanup()
    {
        // The JFrame is not yet created, as panels are prepared as an argument to it, so run a Thread until we find it
        new Thread("OtsSimulationControlPanel cleanup preparation")
        {
            @Override
            public void run()
            {
                JFrame root = null;
                int n = 0;
                while (root == null && n++ < 500) // should wait for about 5s max
                {
                    try
                    {
                        Thread.sleep(10);
                    }
                    catch (InterruptedException exception)
                    {
                        // nothing to do
                    }
                    root = (JFrame) SwingUtilities.getRoot(OtsSimulationControlPanel.this);
                }
                root.addWindowListener(new WindowAdapter()
                {
                    @Override
                    public void windowClosing(final WindowEvent e)
                    {
                        if (OtsSimulationControlPanel.this.simulator != null)
                        {
                            try
                            {
                                if (OtsSimulationControlPanel.this.simulator.isStartingOrRunning())
                                {
                                    OtsSimulationControlPanel.this.simulator.stop();
                                }
                            }
                            catch (SimRuntimeException exception)
                            {
                                exception.printStackTrace();
                            }
                        }
                    }

                    @Override
                    public void windowClosed(final WindowEvent e)
                    {
                        cleanup();
                    }
                });
            }
        }.start();
    }

    /**
     * Set the time warp factor to the best possible approximation of a given value.
     * @param factor the requested speed factor
     */
    public void setSpeedFactor(final double factor)
    {
        int bestStep = -1;
        double bestError = Double.MAX_VALUE;
        double logOfFactor = Math.log(factor);
        for (int step = this.speedSlider.getMinimum(); step <= this.speedSlider.getMaximum(); step++)
        {
            double ratio = this.tickValues.get(step); // stepToFactor(step);
            double logError = Math.abs(logOfFactor - Math.log(ratio));
            if (logError < bestError)
            {
                bestStep = step;
                bestError = logError;
            }
        }
        Logger.ots().trace("setSpeedfactor: factor is {}, best slider value is {} current value is {}", factor, bestStep,
                this.speedSlider.getValue());
        if (this.speedSlider.getValue() != bestStep)
        {
            this.speedSlider.setValue(bestStep);
        }
    }

    /**
     * Change the enabled/disabled state of the various simulation control buttons.
     * @param newState true if the buttons should become enabled; false if the buttons should become disabled
     */
    public void setSimulationControlButtons(final boolean newState)
    {
        this.buttonsEnabled = newState;
        setButtonsEnabledState();
    }

    /**
     * Construct and schedule a SimEvent using a Time to specify the execution time.
     * @param executionTime the time at which the event must happen
     * @param priority should be between {@code SimEventInterface.MAX_PRIORITY} and {@code SimEventInterface.MIN_PRIORITY}; most
     *            normal events should use {@code SimEventInterface.NORMAL_PRIORITY}
     * @param executable executable
     * @return the event that was scheduled (the caller should save this if a need to cancel the event may arise later)
     * @throws SimRuntimeException when the {@code executionTime} is in the past
     */
    private SimEventInterface<Duration> scheduleEvent(final Duration executionTime, final short priority,
            final Executable executable) throws SimRuntimeException
    {
        return this.simulator.scheduleEventAbs(executionTime, priority, executable);
    }

    @Override
    public void actionPerformed(final ActionEvent actionEvent)
    {
        String actionCommand = actionEvent.getActionCommand();
        Logger.ots().trace("actionCommand: " + actionCommand);
        try
        {
            if (actionCommand.equals("Step"))
            {
                if (getSimulator().isStartingOrRunning())
                {
                    getSimulator().stop();
                }
                this.simulator.step();
            }
            if (actionCommand.equals("RunPause"))
            {
                if (this.simulator.isStartingOrRunning())
                {
                    Logger.ots().trace("RunPause: Stopping simulator");
                    this.simulator.stop();
                }
                else if (getSimulator().getEventList().size() > 0)
                {
                    Logger.ots().trace("RunPause: Starting simulator");
                    this.simulator.start();
                }
            }
            if (actionCommand.equals("NextTime"))
            {
                if (getSimulator().isStartingOrRunning())
                {
                    Logger.ots().trace("NextTime: Stopping simulator");
                    getSimulator().stop();
                }
                try
                {
                    this.stopAtEvent = scheduleEvent(getSimulator().getSimulatorTime(), SimEventInterface.MIN_PRIORITY,
                            () -> autoPauseSimulator());
                }
                catch (SimRuntimeException exception)
                {
                    Logger.ots().error("Caught an exception while trying to schedule an autoPauseSimulator event "
                            + "at the current simulator time");
                }
                Logger.ots().trace("NextTime: Starting simulator");
                this.simulator.start();
            }
            setButtonsEnabledState();
        }
        catch (Exception exception)
        {
            exception.printStackTrace();
        }
    }

    /**
     * Clean up timers, contexts, threads, etc. that could prevent garbage collection.
     */
    private void cleanup()
    {
        if (!this.isCleanUp)
        {
            this.isCleanUp = true;
            try
            {
                if (this.simulator != null)
                {
                    if (this.simulator.isStartingOrRunning())
                    {
                        System.out.println("Clean-up: stopping simulator.");
                        this.simulator.stop();
                    }
                    getSimulator().cleanUp();
                }

                System.out.println("Clock timer cancelled.");
                if (this.clockLabel != null)
                {
                    this.clockLabel.cancelTimer(); // cancel the timer on the clock panel.
                }
            }
            catch (Throwable exception)
            {
                exception.printStackTrace();
            }
        }
    }

    /**
     * Update the enabled state of all the buttons.
     */
    private void setButtonsEnabledState()
    {
        Logger.ots().trace("FixButtons entered");
        final boolean moreWorkToDo = getSimulator().getEventList().size() > 0;
        for (JButton button : this.buttons)
        {
            final String actionCommand = button.getActionCommand();
            if (actionCommand.equals("Step"))
            {
                button.setEnabled(moreWorkToDo && this.buttonsEnabled);
            }
            else if (actionCommand.equals("RunPause"))
            {
                button.setEnabled(moreWorkToDo && this.buttonsEnabled);
                if (this.simulator.isStartingOrRunning())
                {
                    button.setToolTipText("Pause the simulation");
                    button.setIcon(PAUSE_ICON);
                }
                else
                {
                    button.setToolTipText("Run the simulation at the indicated speed");
                    button.setIcon(PLAY_ICON);
                }
                button.setEnabled(moreWorkToDo && this.buttonsEnabled);
            }
            else if (actionCommand.equals("NextTime"))
            {
                button.setEnabled(moreWorkToDo && this.buttonsEnabled);
            }
            else
            {
                Logger.ots().error(new Exception("Unknown button?"));
            }
        }
        this.speedSlider.setEnabled(this.buttonsEnabled);
        Logger.ots().trace("FixButtons finishing");
    }

    /**
     * Pause the simulator.
     */
    public void autoPauseSimulator()
    {
        Logger.ots().trace("OtsControlPanel.autoPauseSimulator entered");
        if (getSimulator().isStartingOrRunning())
        {
            Duration currentTick = getSimulator().getSimulatorTime();
            Duration nextTick = getSimulator().getEventList().first().getAbsoluteExecutionTime();
            Logger.ots().trace("currentTick is {}", currentTick);
            Logger.ots().trace("nextTick is {}", nextTick);
            if (nextTick.gt(currentTick))
            {
                // The clock is now just beyond where it was when the user requested the NextTime operation
                // Insert another autoPauseSimulator event just before what is now the time of the next event
                // and let the simulator time increment to that time
                Logger.ots().trace("Re-Scheduling at " + nextTick);
                try
                {
                    this.stopAtEvent = scheduleEvent(nextTick, SimEventInterface.MAX_PRIORITY, () -> autoPauseSimulator());
                    Logger.ots().trace("AutoPauseSimulator: starting simulator");
                }
                catch (SimRuntimeException exception)
                {
                    Logger.ots()
                            .error("Caught an exception while trying to re-schedule an autoPauseEvent at the next real event");
                }
            }
            else
            {
                try
                {
                    Logger.ots().trace("AutoPauseSimulator: stopping simulator");
                    getSimulator().stop();
                }
                catch (SimRuntimeException exception1)
                {
                    exception1.printStackTrace();
                }
                Logger.ots().trace("Not re-scheduling");
                if (SwingUtilities.isEventDispatchThread())
                {
                    Logger.ots().trace("Already on EventDispatchThread");
                    setButtonsEnabledState();
                }
                else
                {
                    try
                    {
                        Logger.ots().trace("Current thread is NOT EventDispatchThread: " + Thread.currentThread());
                        SwingUtilities.invokeAndWait(new Runnable()
                        {
                            @Override
                            public void run()
                            {
                                Logger.ots().trace("Runnable started");
                                setButtonsEnabledState();
                                Logger.ots().trace("Runnable finishing");
                            }
                        });
                    }
                    catch (Exception e)
                    {
                        if (e instanceof InterruptedException)
                        {
                            Logger.ots().error(e);
                            // e.printStackTrace();
                        }
                        else
                        {
                            e.printStackTrace();
                        }
                    }
                }
            }
        }
        Logger.ots().trace("OtsControlPanel.autoPauseSimulator finished");
    }

    @Override
    public void propertyChange(final PropertyChangeEvent evt)
    {
        // timeEdit value changed, schedule stop event (and cancel possible previous event)
        Logger.ots().trace("PropertyChanged: " + evt);
        if (null != this.stopAtEvent)
        {
            getSimulator().cancelEvent(this.stopAtEvent); // silently ignore false result
            this.stopAtEvent = null;
        }
        String newValue = (String) evt.getNewValue();
        String[] fields = newValue.split("[:\\" + this.decimalSeparator + "]");
        int hours = Integer.parseInt(fields[0]);
        int minutes = Integer.parseInt(fields[1]);
        int seconds = Integer.parseInt(fields[2]);
        int fraction = Integer.parseInt(fields[3]);
        double stopTime = hours * 3600 + minutes * 60 + seconds + fraction / 1000d;
        if (stopTime < getSimulator().getSimulatorTime().getSI())
        {
            return;
        }
        else
        {
            try
            {
                this.stopAtEvent =
                        scheduleEvent(Duration.ofSI(stopTime), SimEventInterface.MAX_PRIORITY, () -> autoPauseSimulator());
            }
            catch (SimRuntimeException exception)
            {
                Logger.ots().error("Caught an exception while trying to schedule an autoPauseSimulator event");
            }
        }
    }

    /**
     * Return simulator.
     * @return simulator
     */
    public OtsSimulatorInterface getSimulator()
    {
        return this.simulator;
    }

    /**
     * Return time font.
     * @return font for the time display
     */
    public Font getTimeFont()
    {
        return this.timeFont;
    }

    @Override
    public void notify(final Event event)
    {
        if (event.getType().equals(Replication.END_REPLICATION_EVENT) || event.getType().equals(SimulatorInterface.START_EVENT)
                || event.getType().equals(SimulatorInterface.STOP_EVENT)
                || event.getType().equals(DevsRealTimeAnimator.CHANGE_SPEED_FACTOR_EVENT))
        {
            Logger.ots().trace("OtsControlPanel receive event " + event);
            if (event.getType().equals(DevsRealTimeAnimator.CHANGE_SPEED_FACTOR_EVENT))
            {
                setSpeedFactor((Double) event.getContent());
                return;
            }
            else if (event.getType().equals(Replication.END_REPLICATION_EVENT))
            {
                this.buttonsEnabled = false;
            }
            setButtonsEnabledState();
        }
    }

    @Override
    public String toString()
    {
        return "OtsControlPanel [simulatorTime=" + this.simulator.getSimulatorTime() + "]";
    }

    /**
     * {@link JLabel} that displays the simulation time.
     */
    private final class ClockLabel extends JLabel implements AppearanceControl
    {

        /** Serialization version UID. */
        private static final long serialVersionUID = 20141211L;

        /** The JLabel that displays the time. */
        private final JLabel speedLabel;

        /** The timer (so we can cancel it). */
        private Timer timer;

        /** Timer update interval in msec. */
        private static final long UPDATEINTERVAL = 1000;

        /** Simulation time time. */
        private double prevSimTime = 0;

        /**
         * Construct a clock panel.
         * @param speedLabel speed label
         */
        private ClockLabel(final JLabel speedLabel)
        {
            super("00:00:00" + OtsSimulationControlPanel.this.decimalSeparator + "000");
            this.speedLabel = speedLabel;
            speedLabel.setFont(getTimeFont());
            setFont(getTimeFont());
            setHorizontalAlignment(SwingConstants.RIGHT);
            setOpaque(true);
            this.timer = new Timer();
            this.timer.scheduleAtFixedRate(new TimeUpdateTask(), 0, ClockLabel.UPDATEINTERVAL);
            addMouseListener(new MouseAdapter()
            {
                @Override
                public void mouseClicked(final MouseEvent e)
                {
                    if (!OtsSimulationControlPanel.this.buttonsEnabled)
                    {
                        return;
                    }
                    setVisible(false);
                    OtsSimulationControlPanel.this.timeEdit.setVisible(true);
                    OtsSimulationControlPanel.this.timeEdit.requestFocus();
                    getParent().invalidate();
                }
            });
        }

        /**
         * Cancel the timer task.
         */
        public void cancelTimer()
        {
            if (this.timer != null)
            {
                this.timer.cancel();
            }
            this.timer = null;
        }

        /** Updater for the clock panel. */
        private class TimeUpdateTask extends TimerTask
        {
            /**
             * Constructor.
             */
            TimeUpdateTask()
            {
            }

            @Override
            public void run()
            {
                double now = Math.round(getSimulator().getSimulatorTime().getSI() * 1000) / 1000d;
                int seconds = (int) Math.floor(now);
                int h = (int) seconds / 3600;
                int m = (int) (seconds - h * 3600) / 60;
                double s = now - h * 3600 - m * 60;
                ClockLabel.this.setText(String.format("  %02d:%02d:%06.3f ", h, m, s));
                ClockLabel.this.repaint();
                double speed = getSpeed(now);
                if (Double.isNaN(speed))
                {
                    getSpeedLabel().setText("");
                }
                else
                {
                    getSpeedLabel().setText(String.format("% 5.2fx  ", speed));
                }
                getSpeedLabel().repaint();
            }

            @Override
            public final String toString()
            {
                return "TimeUpdateTask of ClockPanel";
            }
        }

        /**
         * Return speed label.
         * @return speed label
         */
        private JLabel getSpeedLabel()
        {
            return this.speedLabel;
        }

        /**
         * Returns the simulation speed.
         * @param t simulation time
         * @return simulation speed
         */
        private double getSpeed(final double t)
        {
            double speed = (t - this.prevSimTime) / (0.001 * UPDATEINTERVAL);
            this.prevSimTime = t;
            return speed;
        }

        @Override
        public boolean isForeground()
        {
            return true;
        }

        @Override
        public boolean isBackground()
        {
            return true;
        }

        @Override
        public void setBackground(final Color color)
        {
            double f = 0.92;
            super.setBackground(
                    new Color((int) (color.getRed() * f), (int) (color.getGreen() * f), (int) (color.getBlue() * f)));
        }

        @Override
        public OptionalInt getFontSize()
        {
            return OptionalInt.empty();
        }

        @Override
        public String toString()
        {
            return "ClockPanel";
        }

    }

    /** Entry field for simulate until time. */
    private final class TimeEdit extends JFormattedTextField implements AppearanceControl
    {

        /** Serialization version UID. */
        private static final long serialVersionUID = 20141212L;

        /** Last caret position in the time editor. USed to know whether to skip left or right around ':', '.' or ','. */
        private int lastCaretPosition = -1;

        /**
         * Construct a new TimeEdit.
         * @param initialValue the initial value for the TimeEdit
         */
        private TimeEdit(final Time initialValue)
        {
            super(new RegexFormatter(
                    "\\d{2,}:[0-5]\\d:[0-5]\\d\\" + OtsSimulationControlPanel.this.decimalSeparator + "\\d\\d\\d"));
            addKeyListener(new KeyAdapter()
            {
                @Override
                public void keyPressed(final KeyEvent e)
                {
                    String value = getText();
                    int caretPosition = getCaretPosition();
                    ((RegexFormatter) getFormatter()).setOverwriteMode(caretPosition > value.indexOf(':') - 2);
                }
            });
            addCaretListener((e) ->
            {
                String value = getText();
                int caretPosition = getCaretPosition();
                if (value.length() - 1 > caretPosition && (value.charAt(caretPosition) == ':'
                        || value.charAt(caretPosition) == '.' || value.charAt(caretPosition) == ','))
                {
                    caretPosition = caretPosition + (this.lastCaretPosition <= caretPosition ? 1 : -1);
                    this.lastCaretPosition = caretPosition;
                    this.setCaretPosition(caretPosition);
                }
                else if (e.getDot() != e.getMark())
                {
                    this.lastCaretPosition = caretPosition;
                    this.setCaretPosition(caretPosition);
                }
            });
            addFocusListener(new FocusAdapter()
            {
                @Override
                public void focusLost(final FocusEvent e)
                {
                    OtsSimulationControlPanel.this.clockLabel.setVisible(true);
                    setVisible(false);
                    getParent().invalidate();
                }
            });
            OtsSimulationControlPanel.this.addMouseListener(new MouseAdapter()
            {
                @Override
                public void mouseClicked(final MouseEvent e)
                {
                    if (OtsSimulationControlPanel.this.timeEdit.hasFocus())
                    {
                        // removes focus from time edit when the user clicks anywhere on the control panel
                        TimeEdit.this.setFocusable(false);
                        TimeEdit.this.setFocusable(true);
                    }
                    // this listener prevents events from reaching the main panel with pop-up menu, make it appear from here
                    JPanel mainPanel = (JPanel) ((AppearanceApplication) SwingUtilities
                            .getAncestorOfClass(AppearanceApplication.class, OtsSimulationControlPanel.this)).getContentPane();
                    if (e.getButton() == MouseEvent.BUTTON3 && e.getClickCount() == 1
                            && mainPanel.getComponentPopupMenu() != null)
                    {
                        mainPanel.getComponentPopupMenu().show(mainPanel, e.getX(), e.getY());
                    }
                }
            });
            RegexFormatter formatter = (RegexFormatter) getFormatter();
            formatter.setAllowsInvalid(false);
            formatter.setCommitsOnValidEdit(true);
            formatter.setOverwriteMode(true);
            setTime(initialValue);
            setFont(getTimeFont());
            setHorizontalAlignment(SwingConstants.RIGHT);
            setVisible(false);
        }

        /**
         * Set or update the time shown in this TimeEdit.
         * @param newValue the (new) value to set/show in this TimeEdit
         */
        public void setTime(final Time newValue)
        {
            double v = newValue.getSI();
            int seconds = (int) Math.floor(v);
            int h = (int) seconds / 3600;
            int m = (int) (seconds - h * 3600) / 60;
            double s = v - h * 3600 - m * 60;
            this.setText(String.format("%02d:%02d:%06.3f", h, m, s));
        }

        @Override
        public OptionalInt getFontSize()
        {
            return OptionalInt.empty();
        }

        @Override
        public String toString()
        {
            return "TimeEdit [time=" + getText() + "]";
        }

    }

    /**
     * Extension of a {@link DefaultFormatter} that uses a regular expression. For use in the simulate until time edit. <br>
     * Derived from <a href="http://www.java2s.com/Tutorial/Java/0240__Swing/RegexFormatterwithaJFormattedTextField.htm">
     * http://www.java2s.com/Tutorial/Java/0240__Swing/RegexFormatterwithaJFormattedTextField.htm</a>
     * @author Peter Knoppers
     */
    private static final class RegexFormatter extends DefaultFormatter
    {

        /** Serialization version UID. */
        private static final long serialVersionUID = 20141212L;

        /** The regular expression pattern. */
        private Pattern pattern;

        /**
         * Create a new RegexFormatter.
         * @param pattern regular expression pattern that defines what this RexexFormatter will accept
         */
        private RegexFormatter(final String pattern)
        {
            this.pattern = Pattern.compile(pattern);
        }

        @Override
        public Object stringToValue(final String text) throws ParseException
        {
            Matcher matcher = this.pattern.matcher(text);
            if (matcher.matches())
            {
                Logger.ots().trace("String \"" + text + "\" matches");
                return super.stringToValue(text);
            }
            Logger.ots().trace("String \"" + text + "\" does not match");
            throw new ParseException("Pattern did not match", 0);
        }

        @Override
        public String toString()
        {
            return "RegexFormatter [pattern=" + this.pattern + "]";
        }

    }

}