View Javadoc
1   package org.opentrafficsim.swing.gui;
2   
3   import java.awt.Component;
4   import java.awt.Dimension;
5   import java.awt.Font;
6   import java.awt.event.MouseAdapter;
7   import java.awt.event.MouseEvent;
8   import java.io.IOException;
9   import java.util.Dictionary;
10  import java.util.Enumeration;
11  import java.util.LinkedHashMap;
12  import java.util.Map;
13  import java.util.OptionalInt;
14  import java.util.Properties;
15  
16  import javax.imageio.ImageIO;
17  import javax.swing.AbstractButton;
18  import javax.swing.ButtonGroup;
19  import javax.swing.JCheckBoxMenuItem;
20  import javax.swing.JComponent;
21  import javax.swing.JFrame;
22  import javax.swing.JLabel;
23  import javax.swing.JMenu;
24  import javax.swing.JMenuItem;
25  import javax.swing.JPanel;
26  import javax.swing.JPopupMenu;
27  import javax.swing.JSlider;
28  import javax.swing.MenuElement;
29  import javax.swing.MenuSelectionManager;
30  import javax.swing.UIManager;
31  import javax.swing.event.ChangeEvent;
32  import javax.swing.event.ChangeListener;
33  
34  import org.djutils.io.ResourceResolver;
35  import org.opentrafficsim.base.logger.Logger;
36  
37  import nl.tudelft.simulation.dsol.swing.animation.d2.VisualizationPanel;
38  
39  /**
40   * Application with global appearance control. Subclasses should call {@link #setDefaultFont} before any GUI elements are
41   * created (unless this is the first GUI element). Subclasses should call
42   * {@link #setAppearance}{@code (}{@link #getAppearance}{@code )} once all elements have been added to the GUI.
43   * <p>
44   * Copyright (c) 2023-2026 Delft University of Technology, PO Box 5, 2600 AA, Delft, the Netherlands. All rights reserved. <br>
45   * BSD-style license. See <a href="https://opentrafficsim.org/docs/license.html">OpenTrafficSim License</a>.
46   * </p>
47   * @author Wouter Schakel
48   */
49  public class AppearanceApplication extends JFrame
50  {
51  
52      /** Serialization version UID. */
53      private static final long serialVersionUID = 20231017L;
54  
55      /** Font. */
56      private static final Font FONT = new Font("Dialog", Font.PLAIN, AppearanceControl.DEFAULT_FONT_SIZE.getAsInt());
57  
58      /** Map of font scales. */
59      private static final Map<String, Double> FONT_SCALES = new LinkedHashMap<>();
60  
61      static
62      {
63          FONT_SCALES.put("Small", 10.0 / 12.0);
64          FONT_SCALES.put("Normal", 1.0);
65          FONT_SCALES.put("Large", 14.0 / 12.0);
66          FONT_SCALES.put("Very large", 16.0 / 12.0);
67      }
68  
69      /** Properties for the frame appearance (not simulation related). */
70      private static final PropertiesStore FRAME_PROPERTIES;
71  
72      /** Appearance property key. */
73      private static final String APPEARANCE_KEY = "appearance";
74  
75      /** font size property key. */
76      private static final String FONT_SCALE_KEY = "fontScale";
77  
78      static
79      {
80          Properties defaults = new Properties();
81          defaults.setProperty(APPEARANCE_KEY, "GRAY");
82          defaults.setProperty(FONT_SCALE_KEY, "Normal");
83          FRAME_PROPERTIES = new PropertiesStore(defaults, "appearance", "appearance user settings");
84      }
85  
86      /** Pop-up menu with options. */
87      private final JPopupMenu popMenu;
88  
89      /** Group of appearance items. */
90      private final ButtonGroup appGroup;
91  
92      /** Group of font scale items. */
93      private final ButtonGroup scaleGroup;
94  
95      /** Current appearance. */
96      private Appearance appearance = Appearance.GRAY;
97  
98      /** Current font scale. */
99      private String fontScaleName = "Normal";
100 
101     /**
102      * Constructor that uses the default content pane.
103      */
104     public AppearanceApplication()
105     {
106         this(null);
107     }
108 
109     /**
110      * Constructor that sets the content pane.
111      * @param panel content pane
112      */
113     public AppearanceApplication(final JPanel panel)
114     {
115         /*
116          * Any application is supposed to invoke this before any GUI element is made. However, this may be the first GUI element
117          * whereas no subclass can call this as there are only calls to super. Hence we need to call it here too.
118          */
119         AppearanceApplication.setDefaultFont();
120 
121         if (panel != null)
122         {
123             setContentPane(panel);
124         }
125         try
126         {
127             setIconImage(ImageIO.read(ResourceResolver.resolve("ots-icons/Ots32.png").openStream()));
128         }
129         catch (IOException io)
130         {
131             // accept no icon set
132         }
133 
134         try
135         {
136             this.appearance = Appearance.valueOf(FRAME_PROPERTIES.getProperty(APPEARANCE_KEY).toUpperCase());
137         }
138         catch (IllegalArgumentException ex)
139         {
140             Logger.ots().trace("Unable to load saved appearance. Using GRAY instead.");
141             this.appearance = Appearance.GRAY;
142         }
143         this.fontScaleName = FRAME_PROPERTIES.getProperty(FONT_SCALE_KEY);
144 
145         /** Menu class to only accept the font of an Appearance. */
146         class AppearanceControlMenu extends JMenu implements AppearanceControl
147         {
148             /** Serialization version UID. */
149             private static final long serialVersionUID = 20180206L;
150 
151             /**
152              * Constructor.
153              * @param string string
154              */
155             AppearanceControlMenu(final String string)
156             {
157                 super(string);
158             }
159 
160             @Override
161             public boolean isFont()
162             {
163                 return true;
164             }
165 
166             @Override
167             public String toString()
168             {
169                 return "AppearanceControlMenu []";
170             }
171         }
172 
173         // Appearance menu
174         JMenu app = new AppearanceControlMenu("Appearance");
175         app.addMouseListener(new SubMenuShower(app));
176         this.appGroup = new ButtonGroup();
177         for (Appearance appearanceValue : Appearance.values())
178         {
179             this.appGroup.add(addAppearance(app, appearanceValue));
180         }
181         JMenu scale = new AppearanceControlMenu("Font size");
182         scale.addMouseListener(new SubMenuShower(scale));
183         this.scaleGroup = new ButtonGroup();
184         for (String fontScale : FONT_SCALES.keySet())
185         {
186             this.scaleGroup.add(addFontsize(scale, fontScale));
187         }
188 
189         /** PopupMenu class to only accept the font of an Appearance. */
190         class AppearanceControlPopupMenu extends JPopupMenu implements AppearanceControl
191         {
192             /** Serialization version UID. */
193             private static final long serialVersionUID = 20180206L;
194 
195             @Override
196             public boolean isFont()
197             {
198                 return true;
199             }
200 
201             @Override
202             public String toString()
203             {
204                 return "AppearanceControlPopupMenu []";
205             }
206         }
207 
208         // Pop-up menu to change appearance
209         this.popMenu = new AppearanceControlPopupMenu();
210         this.popMenu.add(app);
211         this.popMenu.add(scale);
212         ((JPanel) getContentPane()).setComponentPopupMenu(this.popMenu);
213 
214         // Default preferred size
215         setPreferredSize(new Dimension(1280, 720));
216     }
217 
218     /**
219      * Set font scale.
220      * @param fontScaleKey font scale name
221      */
222     public void setFontScale(final String fontScaleKey)
223     {
224         this.fontScaleName = fontScaleKey;
225         setAppearance(getAppearance());
226     }
227 
228     /**
229      * Sets an appearance.
230      * @param appearance appearance
231      */
232     public void setAppearance(final Appearance appearance)
233     {
234         this.appearance = appearance;
235         setAppearance(this.popMenu, appearance);
236         for (Enumeration<AbstractButton> c = this.appGroup.getElements(); c.hasMoreElements();)
237         {
238             setAppearance(c.nextElement(), appearance);
239         }
240         for (Enumeration<AbstractButton> c = this.scaleGroup.getElements(); c.hasMoreElements();)
241         {
242             setAppearance(c.nextElement(), appearance);
243         }
244         setAppearance(getContentPane(), appearance);
245         FRAME_PROPERTIES.setProperty(APPEARANCE_KEY, appearance.toString(), false);
246         FRAME_PROPERTIES.setProperty(FONT_SCALE_KEY, this.fontScaleName);
247     }
248 
249     /**
250      * Sets an appearance recursively on components.
251      * @param c visual component
252      * @param appear look and feel
253      */
254     private void setAppearance(final Component c, final Appearance appear)
255     {
256         if (c instanceof AppearanceControl)
257         {
258             AppearanceControl ac = (AppearanceControl) c;
259             if (ac.isBackground())
260             {
261                 c.setBackground(appear.getBackground());
262             }
263             if (ac.isForeground())
264             {
265                 c.setForeground(appear.getForeground());
266             }
267             if (ac.isFont())
268             {
269                 changeFont(c, appear.getFont());
270             }
271             if (ac.getFontSize().isPresent())
272             {
273                 changeFontSize(c);
274             }
275         }
276         else if (VisualizationPanel.class.isAssignableFrom(c.getClass()))
277         {
278             // animation backdrop
279             c.setBackground(appear.getBackdrop()); // not background
280             c.setForeground(appear.getForeground());
281             changeFont(c, appear.getFont());
282             changeFontSize(c);
283         }
284         else
285         {
286             // default
287             c.setBackground(appear.getBackground());
288             c.setForeground(appear.getForeground());
289             changeFont(c, appear.getFont());
290             changeFontSize(c);
291         }
292         if (c instanceof JSlider)
293         {
294             // labels of the slider
295             Dictionary<?, ?> dictionary = ((JSlider) c).getLabelTable();
296             Enumeration<?> keys = dictionary.keys();
297             while (keys.hasMoreElements())
298             {
299                 JLabel label = (JLabel) dictionary.get(keys.nextElement());
300                 label.setForeground(appear.getForeground());
301                 label.setBackground(appear.getBackground());
302             }
303         }
304         // children
305         if (c instanceof JComponent)
306         {
307             for (Component child : ((JComponent) c).getComponents())
308             {
309                 setAppearance(child, appear);
310             }
311         }
312     }
313 
314     /**
315      * Change font on component.
316      * @param c component
317      * @param font font name
318      */
319     protected void changeFont(final Component c, final String font)
320     {
321         Font prev = c.getFont();
322         c.setFont(new Font(font, prev.getStyle(), prev.getSize()));
323     }
324 
325     /**
326      * Changes the font size of the component.
327      * @param c component
328      */
329     protected void changeFontSize(final Component c)
330     {
331         Font prev = c.getFont();
332         int size;
333         if (c instanceof AppearanceControl)
334         {
335             AppearanceControl a = (AppearanceControl) c;
336             OptionalInt fontSize = a.getFontSize();
337             if (fontSize.isPresent())
338             {
339                 size = (int) (fontSize.getAsInt() * FONT_SCALES.get(this.fontScaleName));
340             }
341             else
342             {
343                 size = prev.getSize();
344             }
345         }
346         else
347         {
348             size = (int) (AppearanceControl.DEFAULT_FONT_SIZE.getAsInt() * FONT_SCALES.get(this.fontScaleName));
349         }
350         c.setFont(new Font(prev.getFontName(), prev.getStyle(), size));
351     }
352 
353     /**
354      * Returns the appearance.
355      * @return appearance
356      */
357     public Appearance getAppearance()
358     {
359         return this.appearance;
360     }
361 
362     /**
363      * Adds an appearance to the menu.
364      * @param group menu to add item to
365      * @param appear appearance this item selects
366      * @return menu item
367      */
368     private JMenuItem addAppearance(final JMenu group, final Appearance appear)
369     {
370         JCheckBoxMenuItem check = new StayOpenCheckBoxMenuItem(appear.getName(), appear.equals(getAppearance()));
371         check.addMouseListener(new MouseAdapter()
372         {
373             @Override
374             public void mouseClicked(final MouseEvent e)
375             {
376                 setAppearance(appear);
377             }
378         });
379         return group.add(check);
380     }
381 
382     /**
383      * Adds an appearance to the menu.
384      * @param group menu to add item to
385      * @param fontScale font scale name
386      * @return menu item
387      */
388     private JMenuItem addFontsize(final JMenu group, final String fontScale)
389     {
390         JCheckBoxMenuItem check = new StayOpenCheckBoxMenuItem(fontScale, this.fontScaleName.equals(fontScale));
391         check.addMouseListener(new MouseAdapter()
392         {
393             @Override
394             public void mouseClicked(final MouseEvent e)
395             {
396                 setFontScale(fontScale);
397             }
398         });
399         return group.add(check);
400     }
401 
402     /**
403      * Sets default font in the UIManager. This should be invoked by any application before any GUI element is created.
404      */
405     public static void setDefaultFont()
406     {
407         UIManager.put("Label.font", FONT);
408         UIManager.put("Menu.font", FONT);
409         UIManager.put("MenuItem.font", FONT);
410         UIManager.put("TabbedPane.font", FONT);
411         UIManager.put("Table.font", FONT);
412         UIManager.put("TableHeader.font", FONT);
413         UIManager.put("TextField.font", FONT);
414         UIManager.put("Button.font", FONT);
415         UIManager.put("ComboBox.font", FONT);
416         UIManager.put("CheckBox.font", FONT);
417         UIManager.put("CheckBoxMenuItem.font", FONT);
418         // for full list: https://stackoverflow.com/questions/7434845/setting-the-default-font-of-swing-program
419     }
420 
421     /**
422      * Mouse listener which shows the sub-menu when the mouse enters the button.
423      */
424     private class SubMenuShower extends MouseAdapter
425     {
426         /** Menu. */
427         private JMenu menu;
428 
429         /**
430          * Constructor.
431          * @param menu menu
432          */
433         SubMenuShower(final JMenu menu)
434         {
435             this.menu = menu;
436         }
437 
438         @Override
439         public void mouseEntered(final MouseEvent e)
440         {
441             MenuSelectionManager.defaultManager().setSelectedPath(
442                     new MenuElement[] {(MenuElement) this.menu.getParent(), this.menu, this.menu.getPopupMenu()});
443         }
444 
445         @Override
446         public String toString()
447         {
448             return "SubMenuShower [menu=" + this.menu + "]";
449         }
450     }
451 
452     /**
453      * Check box item that keeps the pop-up menu visible after clicking, so the user can click and try some options.
454      */
455     private static class StayOpenCheckBoxMenuItem extends JCheckBoxMenuItem implements AppearanceControl
456     {
457         /** Serialization version UID. */
458         private static final long serialVersionUID = 20180206L;
459 
460         /** Stored selection path. */
461         private static MenuElement[] path;
462         {
463             getModel().addChangeListener(new ChangeListener()
464             {
465                 @Override
466                 public void stateChanged(final ChangeEvent e)
467                 {
468                     if (getModel().isArmed() && isShowing())
469                     {
470                         setPath(MenuSelectionManager.defaultManager().getSelectedPath());
471                     }
472                 }
473             });
474         }
475 
476         /**
477          * Sets the path.
478          * @param path path
479          */
480         public static void setPath(final MenuElement[] path)
481         {
482             StayOpenCheckBoxMenuItem.path = path;
483         }
484 
485         /**
486          * Constructor.
487          * @param text menu item text
488          * @param selected if the item is selected
489          */
490         StayOpenCheckBoxMenuItem(final String text, final boolean selected)
491         {
492             super(text, selected);
493         }
494 
495         @Override
496         public void doClick(final int pressTime)
497         {
498             super.doClick(pressTime);
499             for (MenuElement element : path)
500             {
501                 if (element instanceof JComponent)
502                 {
503                     ((JComponent) element).setVisible(true);
504                 }
505             }
506             JMenu menu = (JMenu) path[path.length - 3];
507             MenuSelectionManager.defaultManager()
508                     .setSelectedPath(new MenuElement[] {(MenuElement) menu.getParent(), menu, menu.getPopupMenu()});
509         }
510 
511         @Override
512         public boolean isFont()
513         {
514             return true;
515         }
516 
517         @Override
518         public String toString()
519         {
520             return "StayOpenCheckBoxMenuItem []";
521         }
522     }
523 
524 }