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