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