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