001package jmri.util.gui;
002
003import java.awt.Font;
004import java.util.ArrayList;
005import java.util.Collection;
006import java.util.Enumeration;
007import java.util.HashSet;
008import java.util.List;
009import java.util.Locale;
010import java.util.Set;
011import java.util.prefs.BackingStoreException;
012import java.util.prefs.Preferences;
013import javax.annotation.Nonnull;
014import javax.swing.ToolTipManager;
015import javax.swing.UIManager;
016import javax.swing.UIManager.LookAndFeelInfo;
017import javax.swing.UnsupportedLookAndFeelException;
018import jmri.InstanceManagerAutoDefault;
019import jmri.beans.Bean;
020import jmri.profile.Profile;
021import jmri.profile.ProfileUtils;
022import jmri.spi.PreferencesManager;
023import jmri.util.prefs.InitializationException;
024import org.openide.util.lookup.ServiceProvider;
025import org.slf4j.Logger;
026import org.slf4j.LoggerFactory;
027
028/**
029 * Manage GUI Look and Feel (LAF) preferences.
030 *
031 * @author Randall Wood (C) 2015, 2020
032 */
033@ServiceProvider(service = PreferencesManager.class)
034public class GuiLafPreferencesManager extends Bean implements PreferencesManager, InstanceManagerAutoDefault {
035
036    public static final String FONT_NAME = "fontName";
037    public static final String FONT_SIZE = "fontSize";
038    public static final String LOCALE = "locale";
039    public static final String LOOK_AND_FEEL = "lookAndFeel";
040    public static final String NONSTANDARD_MOUSE_EVENT = "nonstandardMouseEvent";
041    // Display state in bean tables as icon.
042    public static final String GRAPHIC_TABLE_STATE = "graphicTableState";
043    // Classic OBlock editor or tabbed tables
044    public static final String OBLOCK_EDIT_TABBED = "oblockEditTabbed";
045    public static final String VERTICAL_TOOLBAR = "verticalToolBar";
046    public static final String SHOW_TOOL_TIP_TIME = "showToolTipDismissDelay";
047    public static final String EDITOR_USE_OLD_LOC_SIZE = "editorUseOldLocSize";
048    public static final String JFILECHOOSER_FORMAT = "jfilechooserformat";
049    public static final String MAX_COMBO_ROWS = "maxComboRows";
050    /**
051     * Smallest font size a user can set the font size to other than zero
052     * ({@value}). A font size of 0 indicates that the system default font size
053     * will be used.
054     *
055     * @see apps.GuiLafConfigPane#MIN_DISPLAYED_FONT_SIZE
056     */
057    public static final int MIN_FONT_SIZE = 9;
058    /**
059     * Largest font size a user can set the font size to ({@value}).
060     *
061     * @see apps.GuiLafConfigPane#MAX_DISPLAYED_FONT_SIZE
062     */
063    public static final int MAX_FONT_SIZE = 36;
064    public static final String PROP_DIRTY = "dirty";
065    public static final String PROP_RESTARTREQUIRED = "restartRequired";
066    public static final String DEFAULT_FONT = "List.font";
067
068    // preferences with default values
069    private Locale locale = Locale.getDefault();
070    private Font currentFont = null;
071    private Font defaultFont = null;
072    private int fontSize = 0;
073    private int defaultFontSize = 11; 
074    private boolean nonStandardMouseEvent = false;
075    private boolean graphicTableState = false;
076    private boolean oblockEditTabbed = false;
077    private boolean editorUseOldLocSize = false;
078    private int jFileChooserFormat = 0;
079    private String lookAndFeel = UIManager.getLookAndFeel().getClass().getName();
080    private int toolTipDismissDelay = ToolTipManager.sharedInstance().getDismissDelay();
081    private int maxComboRows = 0;
082    private boolean dirty = false;
083    private boolean restartRequired = false;
084
085    /*
086     * Unlike most PreferencesProviders, the GUI Look & Feel preferences should
087     * be per-application instead of per-profile.
088     */
089    private boolean initialized = false;
090    private final List<InitializationException> exceptions = new ArrayList<>();
091    private static final Logger log = LoggerFactory.getLogger(GuiLafPreferencesManager.class);
092
093    @Override
094    public void initialize(Profile profile) throws InitializationException {
095        if (!this.initialized) {
096            Preferences preferences = ProfileUtils.getPreferences(profile, this.getClass(), true);
097            this.setLocale(Locale.forLanguageTag(preferences.get(LOCALE, this.getLocale().toLanguageTag())));
098            this.setLookAndFeel(preferences.get(LOOK_AND_FEEL, this.getLookAndFeel()));
099
100            this.setDefaultFontSize(); // before we change anything
101            this.setFontSize(preferences.getInt(FONT_SIZE, this.getDefaultFontSize()));
102            if (this.getFontSize() == 0) {
103                this.setFontSize(this.getDefaultFontSize());
104            }
105
106            this.setFontByName(preferences.get(FONT_NAME, this.getDefaultFont().getFontName()));
107            if (this.getFont() == null) {
108                this.setFont(this.getDefaultFont());
109            }
110
111            this.setNonStandardMouseEvent(
112                    preferences.getBoolean(NONSTANDARD_MOUSE_EVENT, this.isNonStandardMouseEvent()));
113            this.setGraphicTableState(preferences.getBoolean(GRAPHIC_TABLE_STATE, this.isGraphicTableState()));
114            this.setOblockEditTabbed(preferences.getBoolean(OBLOCK_EDIT_TABBED, this.isOblockEditTabbed()));
115            this.setEditorUseOldLocSize(preferences.getBoolean(EDITOR_USE_OLD_LOC_SIZE, this.isEditorUseOldLocSize()));
116            this.setJFileChooserFormat(preferences.getInt(JFILECHOOSER_FORMAT, this.getJFileChooserFormat()));
117            this.setMaxComboRows(preferences.getInt(MAX_COMBO_ROWS, this.getMaxComboRows()));
118            this.setToolTipDismissDelay(preferences.getInt(SHOW_TOOL_TIP_TIME, this.getToolTipDismissDelay()));
119
120            log.debug("About to setDefault Locale");
121            Locale.setDefault(this.getLocale());
122            javax.swing.JComponent.setDefaultLocale(this.getLocale());
123
124            this.applyLookAndFeel();
125            this.applyFontSize();
126            this.initialized = true;
127        }
128    }
129
130    @Override
131    public boolean isInitialized(Profile profile) {
132        return this.initialized && this.exceptions.isEmpty();
133    }
134
135    @Override
136    @Nonnull
137    public Collection<Class<? extends PreferencesManager>> getRequires() {
138        return new HashSet<>();
139    }
140
141    @Override
142    @Nonnull
143    public Iterable<Class<?>> getProvides() {
144        Set<Class<?>> provides = new HashSet<>();
145        provides.add(this.getClass());
146        return provides;
147    }
148
149    @Override
150    public void savePreferences(Profile profile) {
151        Preferences preferences = ProfileUtils.getPreferences(profile, this.getClass(), true);
152        preferences.put(LOCALE, this.getLocale().toLanguageTag());
153        preferences.put(LOOK_AND_FEEL, this.getLookAndFeel());
154
155        if (currentFont == null) {
156            currentFont = this.getDefaultFont();
157        }
158
159        String currentFontName = currentFont.getFontName();
160        if (currentFontName != null) {
161            String prefFontName = preferences.get(FONT_NAME, currentFontName);
162            if ((prefFontName == null) || (!prefFontName.equals(currentFontName))) {
163                preferences.put(FONT_NAME, currentFontName);
164            }
165        }
166
167        int temp = this.getFontSize();
168        if (temp == this.getDefaultFontSize()) {
169            temp = 0;
170        }
171        if (temp != preferences.getInt(FONT_SIZE, -1)) {
172            preferences.putInt(FONT_SIZE, temp);
173        }
174        preferences.putBoolean(NONSTANDARD_MOUSE_EVENT, this.isNonStandardMouseEvent());
175        preferences.putBoolean(GRAPHIC_TABLE_STATE, this.isGraphicTableState());
176        preferences.putBoolean(OBLOCK_EDIT_TABBED, this.isOblockEditTabbed());
177        preferences.putBoolean(EDITOR_USE_OLD_LOC_SIZE, this.isEditorUseOldLocSize());
178        preferences.putInt(JFILECHOOSER_FORMAT, this.jFileChooserFormat);
179        preferences.putInt(MAX_COMBO_ROWS, this.getMaxComboRows());
180        preferences.putInt(SHOW_TOOL_TIP_TIME, this.getToolTipDismissDelay());
181        try {
182            preferences.sync();
183        } catch (BackingStoreException ex) {
184            log.error("Unable to save preferences.", ex);
185        }
186        this.setDirty(false);
187    }
188
189    /**
190     * @return the locale
191     */
192    public Locale getLocale() {
193        return locale;
194    }
195
196    /**
197     * @param locale the locale to set
198     */
199    public void setLocale(Locale locale) {
200        Locale oldLocale = this.locale;
201        this.locale = locale;
202        firePropertyChange(LOCALE, oldLocale, locale);
203    }
204
205    /**
206     * @return the currently selected font
207     */
208    public Font getFont() {
209        return currentFont;
210    }
211
212    /**
213     * Sets a new font
214     *
215     * @param newFont the new font to set
216     */
217    public void setFont(Font newFont) {
218        Font oldFont = this.currentFont;
219        this.currentFont = newFont;
220        firePropertyChange(FONT_NAME, oldFont, this.currentFont);
221    }
222
223    /**
224     * Sets a new font by name
225     *
226     * @param newFontName the name of the new font to set
227     */
228    public void setFontByName(String newFontName) {
229        Font oldFont = getFont();
230        if (oldFont == null) {
231            oldFont = this.getDefaultFont();
232        }
233        setFont(new Font(newFontName, oldFont.getStyle(), fontSize));
234    }
235
236    /**
237     * @return the current Look and Feel default font
238     */
239    public Font getDefaultFont() {
240        if (defaultFont == null) {
241            setDefaultFont();
242        }
243        return defaultFont;
244    }
245
246    /**
247     * Called to load the current Look and Feel default font, based on
248     * looking up the {@value #DEFAULT_FONT}.
249     */
250    public void setDefaultFont() {
251        java.util.Enumeration<Object> keys = UIManager.getDefaults().keys();
252        while (keys.hasMoreElements()) {
253            Object key = keys.nextElement();
254            Object value = UIManager.get(key);
255
256            if (value instanceof javax.swing.plaf.FontUIResource && key.toString().equals(DEFAULT_FONT)) {
257                Font f = UIManager.getFont(key);
258                log.debug("Key:{} Font: {}", key, f.getName());
259                defaultFont = f;
260                return;
261            }
262        }
263        // couldn't find the default return a reasonable font
264        defaultFont = UIManager.getFont(DEFAULT_FONT);
265        if (defaultFont == null) {
266            // or maybe not quite as reasonable
267            defaultFont = UIManager.getFont("TextArea.font");
268        }
269    }
270
271    /**
272     * @return the currently selected font size
273     */
274    public int getFontSize() {
275        if (fontSize == 0) {
276            return defaultFontSize;
277        }
278        return fontSize;
279    }
280
281    /**
282     * Set the new font size. If newFontSize is non-zero and less than
283     * {@value #MIN_FONT_SIZE}, the font size is set to {@value #MIN_FONT_SIZE}
284     * or if greater than {@value #MAX_FONT_SIZE}, the font size is set to
285     * {@value #MAX_FONT_SIZE}.
286     *
287     * @param newFontSize the new font size to set
288     */
289    public void setFontSize(int newFontSize) {
290        int oldFontSize = this.fontSize;
291        if (newFontSize != 0 && newFontSize < MIN_FONT_SIZE) {
292            this.fontSize = MIN_FONT_SIZE;
293        } else if (newFontSize > MAX_FONT_SIZE) {
294            this.fontSize = MAX_FONT_SIZE;
295        } else {
296            this.fontSize = newFontSize;
297        }
298        firePropertyChange(FONT_SIZE, oldFontSize, this.fontSize);
299    }
300
301    /**
302     * Get the default font size for the current Look and Feel.
303     *
304     * @return the default font size
305     */
306    public int getDefaultFontSize() {
307        return defaultFontSize;
308    }
309
310    /**
311     * Get the default font size for the current Look and Feel, based
312     * on looking up the {@value #DEFAULT_FONT} size.
313     */
314    public void setDefaultFontSize() {
315        java.util.Enumeration<Object> keys = UIManager.getDefaults().keys();
316        while (keys.hasMoreElements()) {
317            Object key = keys.nextElement();
318            Object value = UIManager.get(key);
319
320            if (value instanceof javax.swing.plaf.FontUIResource && key.toString().equals(DEFAULT_FONT)) {
321                Font f = UIManager.getFont(key);
322                log.debug("Key:{} Font: {} size: {}", key, f.getName(), f.getSize());
323                defaultFontSize = f.getSize();
324                return;
325            }
326        }
327        defaultFontSize = 11; // couldn't find the default return a reasonable
328                              // font size
329    }
330
331    /**
332     * Logs LAF fonts at the TRACE level.
333     */
334    private void logAllFonts() {
335        // avoid any activity if logging at this level is disabled to avoid
336        // the unnecessary overhead of getting the fonts
337        if (log.isTraceEnabled()) {
338            log.trace("******** LAF={}", UIManager.getLookAndFeel().getClass().getName());
339            java.util.Enumeration<Object> keys = UIManager.getDefaults().keys();
340            while (keys.hasMoreElements()) {
341                Object key = keys.nextElement();
342                Object value = UIManager.get(key);
343                if (value != null &&
344                        (value instanceof javax.swing.plaf.FontUIResource ||
345                                value instanceof java.awt.Font ||
346                                key.toString().endsWith(".font"))) {
347                    Font f = UIManager.getFont(key);
348                    log.trace("Class={}; Key: {} Font: {} size: {}", value.getClass().getName(), key, f.getName(),
349                            f.getSize());
350                }
351            }
352        }
353    }
354
355    /**
356     * Sets the time a tooltip is displayed before it goes away.
357     * <p>
358     * Note that this preference takes effect immediately.
359     *
360     * @param time the delay in seconds.
361     */
362    public void setToolTipDismissDelay(int time) {
363        int old = this.toolTipDismissDelay;
364        this.toolTipDismissDelay = time;
365        ToolTipManager.sharedInstance().setDismissDelay(time);
366        firePropertyChange(SHOW_TOOL_TIP_TIME, old, time);
367    }
368
369    /**
370     * Get the time a tooltip is displayed before being dismissed.
371     *
372     * @return the delay in seconds
373     */
374    public int getToolTipDismissDelay() {
375        return this.toolTipDismissDelay;
376    }
377
378    /**
379     * @return the nonStandardMouseEvent
380     */
381    public boolean isNonStandardMouseEvent() {
382        return nonStandardMouseEvent;
383    }
384
385    /**
386     * @param nonStandardMouseEvent the nonStandardMouseEvent to set
387     */
388    public void setNonStandardMouseEvent(boolean nonStandardMouseEvent) {
389        boolean oldNonStandardMouseEvent = this.nonStandardMouseEvent;
390        this.nonStandardMouseEvent = nonStandardMouseEvent;
391        firePropertyChange(NONSTANDARD_MOUSE_EVENT, oldNonStandardMouseEvent, nonStandardMouseEvent);
392    }
393
394    /**
395     * @return the graphicTableState
396     */
397    public boolean isGraphicTableState() {
398        return graphicTableState;
399    }
400
401    /**
402     * @param graphicTableState the graphicTableState to set
403     */
404    public void setGraphicTableState(boolean graphicTableState) {
405        boolean oldGraphicTableState = this.graphicTableState;
406        this.graphicTableState = graphicTableState;
407        firePropertyChange(GRAPHIC_TABLE_STATE, oldGraphicTableState, graphicTableState);
408    }
409
410    /**
411     * @return the graphicTableState
412     */
413    public boolean isOblockEditTabbed() {
414        return oblockEditTabbed;
415    }
416
417    /**
418     * @param tabbed the Editor interface to set (fasle  = desktop)
419     */
420    public void setOblockEditTabbed(boolean tabbed) {
421        boolean oldOblockTabbed = this.oblockEditTabbed;
422        this.oblockEditTabbed = tabbed;
423        firePropertyChange(OBLOCK_EDIT_TABBED, oldOblockTabbed, tabbed);
424    }
425
426    /**
427     * @return the number of combo box rows to be displayed.
428     */
429    public int getMaxComboRows() {
430        return maxComboRows;
431    }
432
433    /**
434     * Set a new value for the number of combo box rows to be displayed.
435     * @param maxRows The new value, zero for no limit
436     */
437    public void setMaxComboRows(int maxRows) {
438        maxComboRows = maxRows;
439    }
440
441    /**
442     * @return the editorUseOldLocSize value
443     */
444    public boolean isEditorUseOldLocSize() {
445        return editorUseOldLocSize;
446    }
447
448    /**
449     * @param editorUseOldLocSize the editorUseOldLocSize value to set
450     */
451    public void setEditorUseOldLocSize(boolean editorUseOldLocSize) {
452        boolean oldEditorUseOldLocSize = this.editorUseOldLocSize;
453        this.editorUseOldLocSize = editorUseOldLocSize;
454        firePropertyChange(EDITOR_USE_OLD_LOC_SIZE, oldEditorUseOldLocSize, editorUseOldLocSize);
455    }
456
457    /**
458     * JFileChooser Type
459     * @return 0 default, 1 List 2 Detail
460     */
461    public int getJFileChooserFormat() {
462        return jFileChooserFormat;
463    }
464
465    /**
466     * @param jFileChooserFormat the JFileChooser 0 default, 1 list, 2 detail
467     */
468    public void setJFileChooserFormat( int jFileChooserFormat) {
469        int oldjFileChooserFormat = this.jFileChooserFormat;
470        this.jFileChooserFormat = jFileChooserFormat;
471        firePropertyChange(JFILECHOOSER_FORMAT, oldjFileChooserFormat, jFileChooserFormat);
472    }
473
474    /**
475     * Get the name of the class implementing the preferred look and feel. Note
476     * this may not be the in-use look and feel if the preferred look and feel
477     * is not available on the current platform; and will be overwritten if
478     * preferences are saved on a platform where the preferred look and feel is
479     * not available.
480     *
481     * @return the look and feel class name
482     */
483    public String getLookAndFeel() {
484        return lookAndFeel;
485    }
486
487    /**
488     * Set the name of the class implementing the preferred look and feel. Note
489     * this change only takes effect after the application is restarted, because
490     * Java has some issues setting the look and feel correctly on already open
491     * windows.
492     *
493     * @param lookAndFeel the look and feel class name
494     */
495    public void setLookAndFeel(String lookAndFeel) {
496        String oldLookAndFeel = this.lookAndFeel;
497        this.lookAndFeel = lookAndFeel;
498        firePropertyChange(LOOK_AND_FEEL, oldLookAndFeel, lookAndFeel);
499    }
500
501    /**
502     * Apply the existing look and feel.
503     */
504    public void applyLookAndFeel() {
505        String lafClassName = null;
506        for (LookAndFeelInfo LAF : UIManager.getInstalledLookAndFeels()) {
507            // accept either name or classname of look and feel
508            if (LAF.getClassName().equals(this.lookAndFeel) || LAF.getName().equals(this.lookAndFeel)) {
509                lafClassName = LAF.getClassName();
510                break; // use first match, not last match (unlikely to be
511                       // different, but you never know)
512            }
513        }
514        log.debug("Look and feel selection \"{}\" ({})", this.lookAndFeel, lafClassName);
515        if (lafClassName != null) {
516            if (!lafClassName.equals(UIManager.getLookAndFeel().getClass().getName())) {
517                log.debug("Apply look and feel \"{}\" ({})", this.lookAndFeel, lafClassName);
518                try {
519                    UIManager.setLookAndFeel(lafClassName);
520                } catch (ClassNotFoundException ex) {
521                    log.error("Could not find look and feel \"{}\".", this.lookAndFeel);
522                } catch (
523                        IllegalAccessException |
524                        InstantiationException ex) {
525                    log.error("Could not load look and feel \"{}\".", this.lookAndFeel);
526                } catch (UnsupportedLookAndFeelException ex) {
527                    log.error("Look and feel \"{}\" is not supported on this platform.", this.lookAndFeel);
528                }
529            } else {
530                log.debug("Not updating look and feel {} matching existing look and feel", lafClassName);
531            }
532        }
533    }
534
535    /**
536     * Applies a new calculated font size to all found fonts.
537     * <p>
538     * Calls {@link #getCalcFontSize(int) getCalcFontSize} to calculate new size
539     * for each.
540     */
541    private void applyFontSize() {
542        if (log.isTraceEnabled()) {
543            logAllFonts();
544        }
545        if (this.getFontSize() != this.getDefaultFontSize()) {
546            Enumeration<Object> keys = UIManager.getDefaults().keys();
547            while (keys.hasMoreElements()) {
548                Object key = keys.nextElement();
549                Object value = UIManager.get(key);
550                if (value != null &&
551                        (value instanceof javax.swing.plaf.FontUIResource ||
552                                value instanceof java.awt.Font ||
553                                key.toString().endsWith(".font"))) {
554                    UIManager.put(key, UIManager.getFont(key).deriveFont(((Font) value).getStyle(),
555                            getCalcFontSize(((Font) value).getSize())));
556                }
557            }
558            if (log.isTraceEnabled()) {
559                logAllFonts();
560            }
561        }
562    }
563
564    /**
565     * Stand-alone service routine to set the default Locale.
566     * <p>
567     * Intended to be invoked early, as soon as a profile is available, to
568     * ensure the correct language is set as startup proceeds. Must be followed
569     * eventually by a complete {@link #setLocale}.
570     *
571     * @param profile The profile to get the locale from
572     */
573    public static void setLocaleMinimally(Profile profile) {
574        // en is default if a locale preference has not been set
575        String name = ProfileUtils.getPreferences(profile, GuiLafPreferencesManager.class, true).get("locale", "en");
576        log.debug("setLocaleMinimally found language {}, setting", name);
577        Locale.setDefault(new Locale(name));
578        javax.swing.JComponent.setDefaultLocale(new Locale(name));
579    }
580
581    /**
582     * @return a new calculated font size based on difference between default
583     *         size and selected size
584     * @param oldSize the old font size
585     */
586    private int getCalcFontSize(int oldSize) {
587        return oldSize + (this.getFontSize() - this.getDefaultFontSize());
588    }
589
590    /**
591     * Check if preferences need to be saved.
592     *
593     * @return true if preferences need to be saved
594     */
595    public boolean isDirty() {
596        return dirty;
597    }
598
599    /**
600     * Set dirty state.
601     *
602     * @param dirty true if preferences need to be saved
603     */
604    private void setDirty(boolean dirty) {
605        if (this.initialized) {
606            boolean oldDirty = this.dirty;
607            this.dirty = dirty;
608            super.firePropertyChange(PROP_DIRTY, oldDirty, dirty);
609        }
610    }
611
612    /**
613     * Check if application needs to restart to apply preferences.
614     *
615     * @return true if preferences are only applied on application start
616     */
617    public boolean isRestartRequired() {
618        return restartRequired;
619    }
620
621    /**
622     * Set restart required state. Sets the state to true if
623     * {@link #isInitialized(jmri.profile.Profile)} is true.
624     */
625    private void setRestartRequired() {
626        if (initialized && !restartRequired) {
627            restartRequired = true;
628            super.firePropertyChange(PROP_RESTARTREQUIRED, false, restartRequired);
629        }
630    }
631
632    /**
633     * {@inheritDoc}
634     */
635    @Override
636    public void firePropertyChange(String propertyName, boolean oldValue, boolean newValue) {
637        if (oldValue != newValue) {
638            setDirty(true);
639            setRestartRequired();
640            super.firePropertyChange(propertyName, oldValue, newValue);
641        }
642    }
643
644    /**
645     * {@inheritDoc}
646     */
647    @Override
648    public void firePropertyChange(String propertyName, int oldValue, int newValue) {
649        if (oldValue != newValue) {
650            setDirty(true);
651            setRestartRequired();
652            super.firePropertyChange(propertyName, oldValue, newValue);
653        }
654    }
655
656    /**
657     * {@inheritDoc}
658     */
659    @Override
660    public void firePropertyChange(String propertyName, Object oldValue, Object newValue) {
661        if (oldValue == null || newValue == null || oldValue != newValue) {
662            setDirty(true);
663            setRestartRequired();
664            super.firePropertyChange(propertyName, oldValue, newValue);
665        }
666    }
667
668    @Override
669    public boolean isInitializedWithExceptions(Profile profile) {
670        return this.initialized && !this.exceptions.isEmpty();
671    }
672
673    @Override
674    @Nonnull
675    public List<Exception> getInitializationExceptions(Profile profile) {
676        return new ArrayList<>(this.exceptions);
677    }
678
679}