001package jmri.jmrit.throttle.panels;
002
003import java.awt.*;
004import java.awt.event.ActionEvent;
005import java.io.File;
006import java.util.ArrayList;
007
008import javax.annotation.CheckForNull;
009import javax.annotation.Nonnull;
010import javax.swing.*;
011
012import jmri.InstanceManager;
013import jmri.Throttle;
014import jmri.jmrit.throttle.interfaces.FunctionListener;
015import jmri.jmrit.throttle.preferences.ThrottlesPreferences;
016import jmri.util.FileUtil;
017import jmri.util.swing.JmriMouseAdapter;
018import jmri.util.swing.JmriMouseEvent;
019import jmri.util.swing.JmriMouseListener;
020import jmri.util.swing.ResizableImagePanel;
021import jmri.util.com.sun.ToggleOrPressButtonModel;
022import jmri.util.gui.GuiLafPreferencesManager;
023
024import org.jdom2.Element;
025import org.slf4j.Logger;
026import org.slf4j.LoggerFactory;
027
028/**
029 * A JButton to activate functions on the decoder. FunctionButtons have a
030 right-click popupMenu menu with several configuration options:
031 <ul>
032 * <li> Set the text
033 * <li> Set the locking state
034 * <li> Set visibility
035 * <li> Set Font
036 * <li> Set function number identity
037 * </ul>
038 * 
039 * <hr>
040 * This file is part of JMRI.
041 * <p>
042 * JMRI is free software; you can redistribute it and/or modify it under the
043 * terms of version 2 of the GNU General Public License as published by the Free
044 * Software Foundation. See the "COPYING" file for a copy of this license.
045 * <p>
046 * JMRI is distributed in the hope that it will be useful, but WITHOUT ANY
047 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
048 * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
049 *
050 * @author Glen Oberhauser
051 * @author Bob Jacobsen Copyright 2008
052 * @author Lionel Jeanson 2021
053 */
054public class FunctionButton extends JToggleButton {
055
056    private final ArrayList<FunctionListener> listeners;
057    private int identity; // F0, F1, etc
058    private boolean isDisplayed = true;
059    private boolean dirty = false;
060    private boolean isImageOK = false;
061    private boolean isSelectedImageOK = false;
062    private String buttonLabel;
063    private JPopupMenu popupMenu;
064    private FunctionButtonPropertyEditor editor ;
065    private String iconPath;
066    private String selectedIconPath;
067    private String dropFolder;
068    private ToggleOrPressButtonModel _model;
069    private Throttle _throttle;
070    private int img_size = DEFAULT_IMG_SIZE;
071    private static final int BUT_HGHT = 24;
072    private static final int BUT_MAX_WDTH = 256;
073    private static final int BUT_MIN_WDTH = 100;
074
075    public static final int DEFAULT_IMG_SIZE = 48;
076
077    public void destroy() {        
078        if (editor != null) {
079            editor.destroy();
080        }
081        _throttle = null;
082    }
083    
084    /**
085     * Get Button Height.
086     * @return height.
087     */
088    public static int getButtonHeight() {
089        return BUT_HGHT;
090    }
091
092    /**
093     * Get the Button Width.
094     * @return width.
095     */
096    public static int getButtonWidth() {
097        return BUT_MIN_WDTH;
098    }
099
100    /**
101     * Get the Image Button Width.
102     * @return width.
103     */
104    public int getButtonImageSize() {
105        return img_size;
106    }
107
108    /**
109     * Set the Image Button Hieght and Width.
110     * @param is the image size (sqaure image size = width = height)
111     */
112    public void setButtonImageSize(int is) {
113        img_size = is;
114    }
115
116    /**
117     * Construct the FunctionButton.
118     * 
119     * @param withPopupMenu  popup menu on function button available if true
120     */
121    public FunctionButton(boolean withPopupMenu) {
122        super();
123        listeners = new ArrayList<>();
124        initGUI(withPopupMenu);
125    }
126
127    public FunctionButton() {
128        this(true);
129    }
130
131    private void initGUI(boolean withPopupMenu){
132        _model = new ToggleOrPressButtonModel(this, true);
133        setModel(_model);
134        //Add listener to components that can bring up popupMenu menus.
135        if (withPopupMenu) {
136            addMouseListener(JmriMouseListener.adapt(new PopupListener()));
137        }
138        setFont(new Font("Monospaced", Font.PLAIN, InstanceManager.getDefault(GuiLafPreferencesManager.class).getFontSize()));
139        setMargin(new Insets(2, 2, 2, 2));
140        setRolloverEnabled(false);
141        updateLnF();
142    }
143
144    /**
145     * Set the function number this button will operate.
146     *
147     * @param id An integer, minimum 0.
148     */
149    public void setIdentity(int id) {
150        this.identity = id;
151    }
152
153    /**
154     * Get the function number this button operates.
155     *
156     * @return An integer, minimum 0.
157     */
158    public int getIdentity() {
159        return identity;
160    }
161
162    /**
163     * Set the state of the function button.
164     * Does not send update to layout, just updates button status.
165     * <p>
166     * To update AND send to layout use setSelected(boolean).
167     *
168     * @param isOn True if the function should be active.
169     */
170    public void setState(boolean isOn) {
171        super.setSelected(isOn);
172        _model.updateSelected(isOn);
173    }
174
175    /**
176     * Get the state of the function.
177     *
178     * @return true if the function is active.
179     */
180    public boolean getState() {
181        return isSelected();
182    }
183
184    /**
185     * Set the locking state of the button.
186     * <p>
187     * Changes in this parameter are only be sent to the
188     * listeners if the dirty bit is set.
189     *
190     * @param isLockable True if the a clicking and releasing the button changes
191     *                   the function state. False if the state is changed back
192     *                   when the button is released
193     */
194    public void setIsLockable(boolean isLockable) {
195        _model.setLockable(isLockable);
196        if (isDirty()) {
197            for (int i = 0; i < listeners.size(); i++) {
198                listeners.get(i).notifyFunctionLockableChanged(identity, isLockable);
199            }
200        }
201    }
202
203    /**
204     * Get the locking state of the function.
205     *
206     * @return True if the a clicking and releasing the button changes the
207     *         function state. False if the state is changed back when 
208     *         button is released
209     */
210    public boolean getIsLockable() {
211        return _model.getLockable();
212    }
213
214    /**
215     * Set the display state of the button.
216     *
217     * @param displayed True if the button exists False if the button has been
218     *                  removed by the user
219     */
220    public void setDisplay(boolean displayed) {
221        this.isDisplayed = displayed;
222    }
223
224    /**
225     * Get the display state of the button.
226     *
227     * @return True if the button exists False if the button has been removed by
228     *         the user
229     */
230    public boolean getDisplay() {
231        return isDisplayed;
232    }
233
234    /**
235     * Set Function Button Dirty.
236     *
237     * @param dirty True when button has been modified by user, else false.
238     */
239    public void setDirty(boolean dirty) {
240        this.dirty = dirty;
241    }
242
243    /**
244     * Get if Button is Dirty.
245     * @return true when function button has been modified by user.
246     */
247    public boolean isDirty() {
248        return dirty;
249    }
250
251    /**
252     * Get the Button Label.
253     * @return Button Label text.
254     */
255    public String getButtonLabel() {
256        return buttonLabel;
257    }
258
259    /**
260     * Set the Button Label.
261     * @param label Label Text.
262     */
263    public void setButtonLabel(String label) {
264        buttonLabel = label;
265    }
266
267    /**
268     * Set Button Text.
269     * {@inheritDoc}
270     */
271    @Override
272    public void setText(String s) {
273        if (s != null) {
274            buttonLabel = s;
275            if (isImageOK) {
276                setToolTipText(buttonLabel);
277                super.setText(null);
278            } else {
279                super.setText(s);
280            }
281            return;
282        }
283        super.setText(null);
284        if (buttonLabel != null) {
285            setToolTipText(buttonLabel);
286        }
287    }
288
289    /**
290     * Update Button Look and Feel :
291     *    Hide/show it if necessary
292     *    Decide if it should show the label or an image with text as tooltip.
293     *    Button UI updated according to above result.
294     */
295    public void updateLnF() {
296        setFocusable(false); // for throttle window keyboard controls
297        setVisible(isDisplayed);
298        setBorderPainted(!isImageOK());
299        setContentAreaFilled(!isImageOK());
300        if (isImageOK()) { // adjust button for image
301            setText(null);
302            setMinimumSize(new Dimension(img_size, img_size));
303            setMaximumSize(new Dimension(img_size, img_size));
304            setPreferredSize(new Dimension(img_size, img_size));
305        }
306        else { // adjust button for text
307            setText(getButtonLabel());
308            setMinimumSize(new Dimension(FunctionButton.BUT_MIN_WDTH, FunctionButton.BUT_HGHT));
309            setMaximumSize(new Dimension(FunctionButton.BUT_MAX_WDTH, FunctionButton.BUT_HGHT));
310            if (getButtonLabel() != null) {
311                int butWidth = getFontMetrics(getFont()).stringWidth(getButtonLabel()) + 64; // pad out the width a bit
312                butWidth = Math.min(butWidth, FunctionButton.BUT_MAX_WDTH );
313                butWidth = Math.max(butWidth, FunctionButton.BUT_MIN_WDTH );
314                setPreferredSize(new Dimension( butWidth, FunctionButton.BUT_HGHT));
315            } else {
316                setPreferredSize(new Dimension(BUT_MIN_WDTH, BUT_HGHT));
317            }
318        }
319    }
320
321    /**
322     * Change the state of the function.
323     * Sets internal state, setSelected, and sends to listeners.
324     * <p>
325     * To update this button WITHOUT sending to layout, use setState.
326     *
327     * @param newState true = Is Function on, False = Is Function off.
328     */
329    @Override
330    public void setSelected(boolean newState){
331        log.debug("function selected {}", newState);
332        super.setSelected(newState);
333        for (int i = 0; i < listeners.size(); i++) {
334            listeners.get(i).notifyFunctionStateChanged(identity, newState);
335        }
336    }
337
338    /**
339     * Add a listener to this button, probably some sort of keypad panel.
340     *
341     * @param l The FunctionListener that wants notifications via the
342     *          FunctionListener.notifyFunctionStateChanged.
343     */
344    public void addFunctionListener(FunctionListener l) {
345        if (!listeners.contains(l)) {
346            listeners.add(l);
347        }
348    }
349
350    /**
351     * Remove a listener from this button.
352     *
353     * @param l The FunctionListener to be removed
354     */
355    public void removeFunctionListener(FunctionListener l) {
356        listeners.remove(l);
357    }
358
359    /**
360     * Set the folder where droped images in function button property panel will be stored
361     *
362     * @param df the folder path
363     */
364    void setDropFolder(String df) {
365        dropFolder = df;
366    }
367
368    /**
369     * A PopupListener to handle mouse clicks and releases.
370     * Handles the popupMenu menu.
371     */
372    private class PopupListener extends JmriMouseAdapter {
373
374        /**
375         * If the event is the popupMenu trigger, which is dependent on the platform, present the popupMenu menu.
376         * @param e The MouseEvent causing the action.
377         */
378        @Override
379        public void mouseClicked(JmriMouseEvent e) {
380            checkTrigger(e);
381        }
382
383        /**
384         * If the event is the popupMenu trigger, which is dependent on the platform, present the popupMenu menu.
385         * @param e The MouseEvent causing the action.
386         */
387        @Override
388        public void mousePressed(JmriMouseEvent e) {
389            checkTrigger( e);
390        }
391
392        /**
393         * If the event is the popupMenu trigger, which is dependent on the  platform, present the popupMenu menu.
394         * @param e The MouseEvent causing the action.
395         */
396        @Override
397        public void mouseReleased(JmriMouseEvent e) {
398            checkTrigger( e);
399        }
400
401        private void checkTrigger( JmriMouseEvent e) {
402            if (e.isPopupTrigger() && e.getComponent().isEnabled() ) {
403                initPopupMenu();
404                popupMenu.show(e.getComponent(), e.getX(), e.getY());
405            }
406        }
407    }
408
409    private void initPopupMenu() {
410        if (popupMenu == null) {
411            JMenuItem propertiesItem = new JMenuItem(Bundle.getMessage("MenuItemProperties"));
412            propertiesItem.addActionListener((ActionEvent e) -> {
413                if (editor == null) {
414                    editor = new FunctionButtonPropertyEditor(this);
415                }
416                editor.resetProperties();
417                editor.setLocation(MouseInfo.getPointerInfo().getLocation());
418                editor.setVisible(true);
419                editor.setDropFolder(dropFolder);
420            });
421            popupMenu = new JPopupMenu();
422            popupMenu.add(propertiesItem);
423        }
424    }
425
426    /**
427     * Collect the prefs of this object into XML Element.
428     * <ul>
429     * <li> identity
430     * <li> text
431     * <li> isLockable
432     * </ul>
433     *
434     * @return the XML of this object.
435     */
436    public Element getXml() {
437        Element me = new Element("FunctionButton"); // NOI18N
438        me.setAttribute("id", String.valueOf(this.getIdentity()));
439        me.setAttribute("text", this.getButtonLabel());
440        me.setAttribute("isLockable", String.valueOf(this.getIsLockable()));
441        me.setAttribute("isVisible", String.valueOf(this.getDisplay()));
442        if (getFont().getSize() != InstanceManager.getDefault(GuiLafPreferencesManager.class).getFontSize()) {
443            me.setAttribute("fontSize", String.valueOf(this.getFont().getSize()));
444        }
445        me.setAttribute("buttonImageSize", String.valueOf(this.getButtonImageSize()));
446        if (this.getIconPath().startsWith(FileUtil.getUserResourcePath())) {
447            me.setAttribute("iconPath", this.getIconPath().substring(FileUtil.getUserResourcePath().length()));
448        } else {
449            me.setAttribute("iconPath", this.getIconPath());
450        }
451        if (this.getSelectedIconPath().startsWith(FileUtil.getUserResourcePath())) {
452            me.setAttribute("selectedIconPath", this.getSelectedIconPath().substring(FileUtil.getUserResourcePath().length()));
453        } else {
454            me.setAttribute("selectedIconPath", this.getSelectedIconPath());
455        }
456        return me;
457    }
458
459    /**
460     * Check if File exists.
461     * @param name File name
462     * @return true if exists, else false.
463     */
464    private boolean checkFile(String name) {
465        File fp = new File(name);
466        return fp.exists();
467    }
468
469    /**
470     * Set the preferences based on the XML Element.
471     * <ul>
472     * <li> identity
473     * <li> text
474     * <li> isLockable
475     * </ul>
476     *
477     * @param e The Element for this object.
478     */
479    public void setXml(Element e) {
480        final ThrottlesPreferences preferences = InstanceManager.getDefault(ThrottlesPreferences.class); 
481        try {
482            this.setIdentity(e.getAttribute("id").getIntValue());
483            this.setText(e.getAttribute("text").getValue());
484            this.setIsLockable(e.getAttribute("isLockable").getBooleanValue());
485            this.setDisplay(e.getAttribute("isVisible").getBooleanValue());
486            if (e.getAttribute("fontSize") != null) {
487                this.setFont(new Font("Monospaced", Font.PLAIN, e.getAttribute("fontSize").getIntValue()));
488            } else {
489                this.setFont(new Font("Monospaced", Font.PLAIN, InstanceManager.getDefault(GuiLafPreferencesManager.class).getFontSize()));
490            }
491            this.setButtonImageSize( (e.getAttribute("buttonImageSize")!=null)?e.getAttribute("buttonImageSize").getIntValue():DEFAULT_IMG_SIZE);
492            if (preferences.isUsingExThrottle() && preferences.isUsingFunctionIcon() && (e.getAttribute("iconPath") != null) && (e.getAttribute("iconPath").getValue().length() > 0)) {
493                if (checkFile(FileUtil.getUserResourcePath() + e.getAttribute("iconPath").getValue())) {
494                    this.setIconPath(FileUtil.getUserResourcePath() + e.getAttribute("iconPath").getValue());
495                } else {
496                    this.setIconPath(e.getAttribute("iconPath").getValue());
497                }
498            } else {
499                this.setIconPath(null);
500            }
501            if ((e.getAttribute("selectedIconPath") != null) && (e.getAttribute("selectedIconPath").getValue().length() > 0)) {
502                if (checkFile(FileUtil.getUserResourcePath() + e.getAttribute("selectedIconPath").getValue())) {
503                    this.setSelectedIconPath(FileUtil.getUserResourcePath() + e.getAttribute("selectedIconPath").getValue());
504                } else {
505                    this.setSelectedIconPath(e.getAttribute("selectedIconPath").getValue());
506                }
507            } else {
508                this.setSelectedIconPath(null);
509            }
510            updateLnF();
511        } catch (org.jdom2.DataConversionException ex) {
512            log.error("DataConverstionException in setXml", ex);
513        }
514    }
515
516    /**
517     * Set the Icon Path, NON selected.
518     * <p>
519     * Checks image and sets isImageOK flag.
520     * @param fnImg icon path.
521     */
522    public void setIconPath(String fnImg) {
523        iconPath = fnImg;
524        ResizableImagePanel fnImage = new ResizableImagePanel();
525        fnImage.setBackground(new Color(0, 0, 0, 0));
526        fnImage.setRespectAspectRatio(true);
527        fnImage.setSize(new Dimension(img_size,img_size));
528        fnImage.setImagePath(fnImg);
529        if (fnImage.getScaledImage() != null) {
530            setIcon(new ImageIcon(fnImage.getScaledImage()));
531            isImageOK = true;
532        } else {
533            setIcon(null);
534            isImageOK = false;
535        }
536    }
537
538    /**
539     * Get the Icon Path, NON selected.
540     * @return Icon Path, else empty string if null.
541     */
542    @Nonnull
543    public String getIconPath() {
544        if (iconPath == null) {
545            return "";
546        }
547        return iconPath;
548    }
549
550    /**
551     * Set the Selected Icon Path.
552     * <p>
553     * Checks image and sets isSelectedImageOK flag.
554     * @param fnImg selected icon path.
555     */
556    public void setSelectedIconPath(String fnImg) {
557        selectedIconPath = fnImg;
558        ResizableImagePanel fnSelectedImage = new ResizableImagePanel();
559        fnSelectedImage.setBackground(new Color(0, 0, 0, 0));
560        fnSelectedImage.setRespectAspectRatio(true);
561        fnSelectedImage.setSize(new Dimension(img_size, img_size));
562        fnSelectedImage.setImagePath(fnImg);
563        if (fnSelectedImage.getScaledImage() != null) {
564            ImageIcon icon = new ImageIcon(fnSelectedImage.getScaledImage());
565            setSelectedIcon(icon);
566            setPressedIcon(icon);
567            isSelectedImageOK = true;
568        } else {
569            setSelectedIcon(null);
570            setPressedIcon(null);
571            isSelectedImageOK = false;
572        }
573    }
574
575    /**
576     * Get the Selected Icon Path.
577     * @return selected Icon Path, else empty string if null.
578     */
579    @Nonnull
580    public String getSelectedIconPath() {
581        if (selectedIconPath == null) {
582            return "";
583        }
584        return selectedIconPath;
585    }
586
587    /**
588     * Get if isImageOK.
589     * @return true if isImageOK.
590     */
591    public boolean isImageOK() {
592        return isImageOK;
593    }
594
595    /**
596     * Get if isSelectedImageOK.
597     * @return true if isSelectedImageOK.
598     */
599    public boolean isSelectedImageOK() {
600        return isSelectedImageOK;
601    }
602
603    /**
604     * Set Throttle.
605     * @param throttle the throttle that this button is associated with.
606     */
607    protected void setThrottle( Throttle throttle) {
608        _throttle = throttle;
609    }
610
611    /**
612     * Get Throttle for this button.
613     * @return throttle associated with this button.  May be null if no throttle currently associated.
614     */
615    @CheckForNull
616    protected Throttle getThrottle() {
617        return _throttle;
618    }
619
620    private static final Logger log = LoggerFactory.getLogger(FunctionButton.class);
621
622}