AppearanceApplication.java
package org.opentrafficsim.swing.gui;
import java.awt.Component;
import java.awt.Font;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
import java.io.File;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.util.Dictionary;
import java.util.Enumeration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.OptionalInt;
import java.util.Properties;
import javax.imageio.ImageIO;
import javax.swing.AbstractButton;
import javax.swing.ButtonGroup;
import javax.swing.JCheckBoxMenuItem;
import javax.swing.JComponent;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JMenu;
import javax.swing.JMenuItem;
import javax.swing.JPanel;
import javax.swing.JPopupMenu;
import javax.swing.JSlider;
import javax.swing.MenuElement;
import javax.swing.MenuSelectionManager;
import javax.swing.UIManager;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
import org.djutils.io.ResourceResolver;
import org.opentrafficsim.base.logger.Logger;
import nl.tudelft.simulation.dsol.swing.animation.d2.VisualizationPanel;
/**
* Application with global appearance control. Subclasses should call {@code AppearanceApplication.setDefaultFont();} before any
* GUI elements are created (unless this is the first GUI element). Subclasses should call
* {@code setAppearance(getAppearance());} once all elements have been added to the GUI.
* <p>
* Copyright (c) 2023-2024 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 <a href="https://github.com/wjschakel">Wouter Schakel</a>
*/
public class AppearanceApplication extends JFrame
{
/** */
private static final long serialVersionUID = 20231017L;
/** Font. */
private static final Font FONT = new Font("Dialog", Font.PLAIN, AppearanceControl.DEFAULT_FONT_SIZE.getAsInt());
/** Map of font scales. */
private static final Map<String, Double> FONT_SCALES = new LinkedHashMap<>();
static
{
FONT_SCALES.put("Small", 10.0 / 12.0);
FONT_SCALES.put("Normal", 1.0);
FONT_SCALES.put("Large", 14.0 / 12.0);
FONT_SCALES.put("Very large", 16.0 / 12.0);
}
/** Properties for the frame appearance (not simulation related). */
protected Properties frameProperties;
/** Popup menu with options. */
private final JPopupMenu popMenu;
/** Group of appearance items. */
private final ButtonGroup appGroup;
/** Group of font scale items. */
private final ButtonGroup scaleGroup;
/** Current appearance. */
private Appearance appearance = Appearance.GRAY;
/** Current font scale. */
private String fontScaleName = "Normal";
/**
* Constructor that uses the default content pane.
*/
public AppearanceApplication()
{
this(null);
}
/**
* Constructor that sets the content pane.
* @param panel content pane.
*/
public AppearanceApplication(final JPanel panel)
{
/*
* Any application is supposed to invoke this before any GUI element is made. However, this may be the first GUI element
* whereas no subclass can call this as there are only calls to super. Hence we need to call it here too.
*/
AppearanceApplication.setDefaultFont();
if (panel != null)
{
setContentPane(panel);
}
try
{
setIconImage(ImageIO.read(ResourceResolver.resolve("/OTS_merge.png").openStream()));
}
catch (IOException io)
{
// accept no icon set
}
// Listener to write frame properties on frame close
String sep = System.getProperty("file.separator");
String propertiesFile = System.getProperty("user.home") + sep + "OTS" + sep + "properties.ini";
addWindowListener(new WindowAdapter()
{
/** {@inheritDoce} */
@Override
public void windowClosing(final WindowEvent windowEvent)
{
try
{
File f = new File(propertiesFile);
f.getParentFile().mkdirs();
FileWriter writer = new FileWriter(f);
AppearanceApplication.this.frameProperties.store(writer, "OTS user settings");
}
catch (IOException exception)
{
Logger.ots().error("Could not store properties at {}.", propertiesFile);
}
}
});
// Set default frame properties and load properties from file (if any)
Properties defaults = new Properties();
defaults.setProperty("Appearance", "GRAY");
defaults.setProperty("FontScale", "Normal");
this.frameProperties = new Properties(defaults);
try
{
FileReader reader = new FileReader(propertiesFile);
this.frameProperties.load(reader);
}
catch (IOException ioe)
{
// ok, use defaults
}
this.appearance = Appearance.valueOf(this.frameProperties.getProperty("Appearance").toUpperCase());
this.fontScaleName = this.frameProperties.getProperty("FontScale");
/** Menu class to only accept the font of an Appearance */
class AppearanceControlMenu extends JMenu implements AppearanceControl
{
/** */
private static final long serialVersionUID = 20180206L;
/**
* Constructor.
* @param string string
*/
AppearanceControlMenu(final String string)
{
super(string);
}
@Override
public boolean isFont()
{
return true;
}
@Override
public String toString()
{
return "AppearanceControlMenu []";
}
}
// Appearance menu
JMenu app = new AppearanceControlMenu("Appearance");
app.addMouseListener(new SubMenuShower(app));
this.appGroup = new ButtonGroup();
for (Appearance appearanceValue : Appearance.values())
{
this.appGroup.add(addAppearance(app, appearanceValue));
}
JMenu scale = new AppearanceControlMenu("Font size");
scale.addMouseListener(new SubMenuShower(scale));
this.scaleGroup = new ButtonGroup();
for (String fontScaleName : FONT_SCALES.keySet())
{
this.scaleGroup.add(addFontsize(scale, fontScaleName));
}
/** PopupMenu class to only accept the font of an Appearance */
class AppearanceControlPopupMenu extends JPopupMenu implements AppearanceControl
{
/** */
private static final long serialVersionUID = 20180206L;
@Override
public boolean isFont()
{
return true;
}
@Override
public String toString()
{
return "AppearanceControlPopupMenu []";
}
}
// Popup menu to change appearance
this.popMenu = new AppearanceControlPopupMenu();
this.popMenu.add(app);
this.popMenu.add(scale);
((JPanel) getContentPane()).setComponentPopupMenu(this.popMenu);
}
/**
* Set font scale.
* @param fontScaleName font scale name.
*/
public void setFontScale(final String fontScaleName)
{
this.fontScaleName = fontScaleName;
setAppearance(getAppearance());
}
/**
* Sets an appearance.
* @param appearance appearance
*/
public void setAppearance(final Appearance appearance)
{
this.appearance = appearance;
setAppearance(this.popMenu, appearance);
for (Enumeration<AbstractButton> c = this.appGroup.getElements(); c.hasMoreElements();)
{
setAppearance(c.nextElement(), appearance);
}
for (Enumeration<AbstractButton> c = this.scaleGroup.getElements(); c.hasMoreElements();)
{
setAppearance(c.nextElement(), appearance);
}
setAppearance(getContentPane(), appearance);
this.frameProperties.setProperty("Appearance", appearance.toString());
this.frameProperties.setProperty("FontScale", this.fontScaleName);
}
/**
* Sets an appearance recursively on components.
* @param c visual component
* @param appear look and feel
*/
private void setAppearance(final Component c, final Appearance appear)
{
if (c instanceof AppearanceControl)
{
AppearanceControl ac = (AppearanceControl) c;
if (ac.isBackground())
{
c.setBackground(appear.getBackground());
}
if (ac.isForeground())
{
c.setForeground(appear.getForeground());
}
if (ac.isFont())
{
changeFont(c, appear.getFont());
}
if (ac.getFontSize().isPresent())
{
changeFontSize(c);
}
}
else if (VisualizationPanel.class.isAssignableFrom(c.getClass()))
{
// animation backdrop
c.setBackground(appear.getBackdrop()); // not background
c.setForeground(appear.getForeground());
changeFont(c, appear.getFont());
changeFontSize(c);
}
else
{
// default
c.setBackground(appear.getBackground());
c.setForeground(appear.getForeground());
changeFont(c, appear.getFont());
changeFontSize(c);
}
if (c instanceof JSlider)
{
// labels of the slider
Dictionary<?, ?> dictionary = ((JSlider) c).getLabelTable();
Enumeration<?> keys = dictionary.keys();
while (keys.hasMoreElements())
{
JLabel label = (JLabel) dictionary.get(keys.nextElement());
label.setForeground(appear.getForeground());
label.setBackground(appear.getBackground());
}
}
// children
if (c instanceof JComponent)
{
for (Component child : ((JComponent) c).getComponents())
{
setAppearance(child, appear);
}
}
}
/**
* Change font on component.
* @param c component
* @param font font name
*/
protected void changeFont(final Component c, final String font)
{
Font prev = c.getFont();
c.setFont(new Font(font, prev.getStyle(), prev.getSize()));
}
/**
* Changes the font size of the component.
* @param c component.
*/
protected void changeFontSize(final Component c)
{
Font prev = c.getFont();
int size;
if (c instanceof AppearanceControl)
{
AppearanceControl a = (AppearanceControl) c;
OptionalInt fontSize = a.getFontSize();
if (fontSize.isPresent())
{
size = (int) (fontSize.getAsInt() * FONT_SCALES.get(this.fontScaleName));
}
else
{
size = prev.getSize();
}
}
else
{
size = (int) (AppearanceControl.DEFAULT_FONT_SIZE.getAsInt() * FONT_SCALES.get(this.fontScaleName));
}
c.setFont(new Font(prev.getFontName(), prev.getStyle(), size));
}
/**
* Returns the appearance.
* @return appearance
*/
public Appearance getAppearance()
{
return this.appearance;
}
/**
* Adds an appearance to the menu.
* @param group menu to add item to
* @param appear appearance this item selects
* @return menu item
*/
private JMenuItem addAppearance(final JMenu group, final Appearance appear)
{
JCheckBoxMenuItem check = new StayOpenCheckBoxMenuItem(appear.getName(), appear.equals(getAppearance()));
check.addMouseListener(new MouseAdapter()
{
@Override
public void mouseClicked(final MouseEvent e)
{
setAppearance(appear);
}
});
return group.add(check);
}
/**
* Adds an appearance to the menu.
* @param group menu to add item to
* @param fontScaleName font scale name
* @return menu item
*/
private JMenuItem addFontsize(final JMenu group, final String fontScaleName)
{
JCheckBoxMenuItem check = new StayOpenCheckBoxMenuItem(fontScaleName, this.fontScaleName.equals(fontScaleName));
check.addMouseListener(new MouseAdapter()
{
@Override
public void mouseClicked(final MouseEvent e)
{
setFontScale(fontScaleName);
}
});
return group.add(check);
}
/**
* Sets default font in the UIManager. This should be invoked by any application before any GUI element is created.
*/
public static void setDefaultFont()
{
UIManager.put("Label.font", FONT);
UIManager.put("Menu.font", FONT);
UIManager.put("MenuItem.font", FONT);
UIManager.put("TabbedPane.font", FONT);
UIManager.put("Table.font", FONT);
UIManager.put("TableHeader.font", FONT);
UIManager.put("TextField.font", FONT);
UIManager.put("Button.font", FONT);
UIManager.put("ComboBox.font", FONT);
UIManager.put("CheckBox.font", FONT);
UIManager.put("CheckBoxMenuItem.font", FONT);
// for full list: https://stackoverflow.com/questions/7434845/setting-the-default-font-of-swing-program
}
/**
* Mouse listener which shows the submenu when the mouse enters the button.
* <p>
* Copyright (c) 2013-2024 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 <a href="https://github.com/averbraeck">Alexander Verbraeck</a>
* @author <a href="https://github.com/peter-knoppers">Peter Knoppers</a>
* @author <a href="https://github.com/wjschakel">Wouter Schakel</a>
*/
private class SubMenuShower extends MouseAdapter
{
/** The menu. */
private JMenu menu;
/**
* Constructor.
* @param menu menu
*/
SubMenuShower(final JMenu menu)
{
this.menu = menu;
}
@Override
public void mouseEntered(final MouseEvent e)
{
MenuSelectionManager.defaultManager().setSelectedPath(
new MenuElement[] {(MenuElement) this.menu.getParent(), this.menu, this.menu.getPopupMenu()});
}
@Override
public String toString()
{
return "SubMenuShower [menu=" + this.menu + "]";
}
}
/**
* Check box item that keeps the popup menu visible after clicking, so the user can click and try some options.
* <p>
* Copyright (c) 2013-2024 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 <a href="https://github.com/averbraeck">Alexander Verbraeck</a>
* @author <a href="https://github.com/peter-knoppers">Peter Knoppers</a>
* @author <a href="https://github.com/wjschakel">Wouter Schakel</a>
*/
private static class StayOpenCheckBoxMenuItem extends JCheckBoxMenuItem implements AppearanceControl
{
/** */
private static final long serialVersionUID = 20180206L;
/** Stored selection path. */
private static MenuElement[] PATH;
{
getModel().addChangeListener(new ChangeListener()
{
@Override
public void stateChanged(final ChangeEvent e)
{
if (getModel().isArmed() && isShowing())
{
setPath(MenuSelectionManager.defaultManager().getSelectedPath());
}
}
});
}
/**
* Sets the path.
* @param path path
*/
public static void setPath(final MenuElement[] path)
{
StayOpenCheckBoxMenuItem.PATH = path;
}
/**
* Constructor.
* @param text menu item text
* @param selected if the item is selected
*/
StayOpenCheckBoxMenuItem(final String text, final boolean selected)
{
super(text, selected);
}
@Override
public void doClick(final int pressTime)
{
super.doClick(pressTime);
for (MenuElement element : PATH)
{
if (element instanceof JComponent)
{
((JComponent) element).setVisible(true);
}
}
JMenu menu = (JMenu) PATH[PATH.length - 3];
MenuSelectionManager.defaultManager()
.setSelectedPath(new MenuElement[] {(MenuElement) menu.getParent(), menu, menu.getPopupMenu()});
}
@Override
public boolean isFont()
{
return true;
}
@Override
public String toString()
{
return "StayOpenCheckBoxMenuItem []";
}
}
}