ArduinoScrollButton.java

/*******************************************************************************
 * jArduino: Arduino C++ Code Generation From Java
 * Copyright 2020 Tony Washer
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *   http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 ******************************************************************************/
package net.sourceforge.jarduino.gui;

import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Component;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Point;
import java.awt.event.FocusEvent;
import java.awt.event.FocusListener;
import java.awt.event.InputEvent;
import java.awt.event.KeyEvent;
import java.awt.event.KeyListener;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;
import java.util.function.Function;
import javax.swing.BorderFactory;
import javax.swing.BoxLayout;
import javax.swing.Icon;
import javax.swing.JButton;
import javax.swing.JComponent;
import javax.swing.JDialog;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.JScrollBar;
import javax.swing.JScrollPane;
import javax.swing.JViewport;
import javax.swing.ScrollPaneConstants;
import javax.swing.SwingConstants;

/**
 * Button with scrollable popupMenu.
 * @param <T> the type of object
 */
public class ArduinoScrollButton<T> {
    /**
     * Background active colour.
     */
    private static final Color COLOR_BACKGROUND = Color.decode("#5F9EA0");

    /**
     * The Inset size.
     */
    private static final int INSET_SIZE = 5;

    /**
     * Default max items.
     */
    private static final int DEFAULT_MAXITEMS = 10;

    /**
     * Minimum max items.
     */
    private static final int MIN_MAXITEMS = 3;

    /**
     * Default row size.
     */
    private static final int DEFAULT_ROWHEIGHT = 16;

    /**
     * ScrollBar Width.
     */
    private static final int SCROLLBAR_WIDTH = 4;

    /**
     * The frame.
     */
    private final JFrame theFrame;

    /**
     * The button.
     */
    private final JButton theButton;

    /**
     * The items.
     */
    private final List<T> theItems;

    /**
     * The menu.
     */
    private final ArduinoScrollMenu theMenu;

    /**
     * The max # of items.
     */
    private int theMaxItems;

    /**
     * The highlight colour.
     */
    private Color theHighlight;

    /**
     * The formatter.
     */
    private Function<T, String> theFormatter;

    /**
     * The consumer.
     */
    private Consumer<T> theConsumer;

    /**
     * The selected item.
     */
    private T theSelectedItem;

    /**
     * The active item.
     */
    private T theActiveItem;

    /**
     * Constructor.
     * @param pFrame the frame
     */
    ArduinoScrollButton(final JFrame pFrame) {
        /* record parameters */
        theFrame = pFrame;
        theMaxItems = DEFAULT_MAXITEMS;
        theHighlight = COLOR_BACKGROUND;

        /* Create items */
        theItems = new ArrayList<>();
        theMenu = new ArduinoScrollMenu();

        /* Configure the button */
        theButton = new JButton();
        theButton.setIcon(ArduinoArrowIcon.DOWN);
        theButton.setHorizontalAlignment(SwingConstants.CENTER);
        theButton.setVerticalAlignment(SwingConstants.CENTER);
        theButton.setHorizontalTextPosition(SwingConstants.LEFT);
        theButton.addActionListener(e -> theMenu.showMenuBelow(theButton));

        /* Set the default formatter */
        theFormatter = Object::toString;
    }

    /**
     * Obtain the component.
     * @return the component
     */
    public JComponent getComponent() {
        return theButton;
    }

    /**
     * Set the maxItems to display.
     * @param pMaxItems the maximum items
     */
    public void setMaxDisplayItems(final int pMaxItems) {
        theMaxItems = Math.max(MIN_MAXITEMS, pMaxItems);
    }

    /**
     * Obtain the maxItems to display.
     * @return the maxItems
     */
    public int getMaxDisplayItems() {
        return theMaxItems;
    }

    /**
     * Obtain the highlight colour.
     * @return the colour
     */
    public Color getHighlightColor() {
        return theHighlight;
    }

    /**
     * Set the item highlight color.
     * @param pColor the color
     */
    public void setHighlightColor(final Color pColor) {
        theHighlight = pColor;
    }

    /**
     * Add item to menu.
     * @param pItem the item to add
     */
    public void add(final T pItem) {
        if (theItems.isEmpty()) {
            setSelectedItem(pItem);
        }
        theItems.add(pItem);
    }

    /**
     * Remove all items.
     */
    public void removeAll() {
        theItems.clear();
        setSelectedItem(null);
    }

    /**
     * Set the formatter.
     * @param pFormatter the formatter
     */
    public void setFormatter(final Function<T, String> pFormatter) {
        theFormatter = pFormatter;
    }

    /**
     * Set the action consumer.
     * @param pConsumer the consumer
     */
    public void onSelect(final Consumer<T> pConsumer) {
        theConsumer = pConsumer;
    }

    /**
     * Set the selected item.
     * @param pItem the item
     */
    public void setSelectedItem(final T pItem) {
        /* Record the selection */
        theSelectedItem = pItem;
        theButton.setText(getText(pItem));

        /* Pass the item to the consumer */
        if (theConsumer != null && pItem != null) {
            theConsumer.accept(pItem);
        }

        /* Close the menu */
        closeMenu();
    }

    /**
     * Obtain the selected item.
     * @return the item
     */
    public T getSelectedItem() {
        return theSelectedItem;
    }

    /**
     * Close the menu.
     */
    void closeMenu() {
        theMenu.closeMenu();
    }

    /**
     * Obtain the text for the item.
     * @param pItem the item
     * @return the text
     */
    String getText(final T pItem) {
        return pItem == null ? "Select" : theFormatter.apply(pItem);
    }

    /**
     * scrollable popupMenu.
     */
    private class ArduinoScrollMenu {
        /**
         * The dialog.
         */
        private JDialog theDialog;

        /**
         * Build the menu.
         *
         * @return the menu
         */
        private JPanel buildMenu() {
            /* Create the panel */
            final JPanel myMenu = new JPanel();
            myMenu.setLayout(new BoxLayout(myMenu, BoxLayout.Y_AXIS));
            myMenu.setBorder(BorderFactory.createEmptyBorder(INSET_SIZE, INSET_SIZE, INSET_SIZE, INSET_SIZE));

            /* Loop through the items */
            for (T myItem : theItems) {
                /* Create element and add to menu */
                final ArduinoScrollableMenuElement myElement = new ArduinoScrollableMenuElement(myItem);
                myMenu.add(myElement.getComponent());
            }

            /* Return the menu */
            return myMenu;
        }

        /**
         * Close the menu.
         */
        void closeMenu() {
            if (isVisible()) {
                theDialog.setVisible(false);
                theDialog = null;
                theActiveItem = null;
            }
        }

        /**
         * Is the menu visible?
         *
         * @return true/false
         */
        boolean isVisible() {
            return theDialog != null && theDialog.isShowing();
        }

        /**
         * Show the menu below the component.
         *
         * @param pComponent the component
         */
        public void showMenuBelow(final JComponent pComponent) {
            /* Ignore if there is no change */
            if (isVisible()) {
                return;
            }

            /* Create the dialog */
            theDialog = new JDialog(theFrame, false);
            theDialog.setUndecorated(true);

            /* Add listeners */
            final ArduinoScrollListener myListener = new ArduinoScrollListener();
            theDialog.addFocusListener(myListener);
            theDialog.addKeyListener(myListener);

            /* Build the dialog */
            buildDialog(pComponent);

            /* Set the correct location and display */
            final Point myLoc = pComponent.getLocationOnScreen();
            theDialog.setLocation(myLoc.x, myLoc.y
                    + pComponent.getHeight());
            theDialog.setVisible(true);
        }

        /**
         * Build dialog.
         * @param pComponent the anchoring component
         */
        private void buildDialog(final JComponent pComponent) {
            /* Access the number of entries and the scroll count */
            final int myCount = theItems.size();
            final int myScrollCount = Math.min(theMaxItems, myCount);

            /* Build the menu */
            JComponent myPanel = buildMenu();

            /* If we need to scroll */
            if (myScrollCount != myCount) {
                /* Create scroll pane and set its size */
                final JScrollPane myScroll = new JScrollPane(myPanel);
                myScroll.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
                final JViewport myView = myScroll.getViewport();
                final Dimension myPrefSize = myView.getPreferredSize();
                final int myLen = theMaxItems * DEFAULT_ROWHEIGHT + 1;
                myView.setPreferredSize(new Dimension(myPrefSize.width + SCROLLBAR_WIDTH, myLen));
                myPanel = myScroll;

                /* Adjust the sensitivity of the scroll bar */
                final JScrollBar myBar = myScroll.getVerticalScrollBar();
                myBar.setUnitIncrement(DEFAULT_ROWHEIGHT);

                /* consume mouseWheel events to prevent menu closing */
                theDialog.addMouseWheelListener(InputEvent::consume);
            }

            /* Create a new panel and set a line border */
            final JPanel myMain = new JPanel(new BorderLayout());
            myMain.add(myPanel, BorderLayout.CENTER);
            myMain.setBorder(BorderFactory.createLineBorder(Color.BLACK));

            /* Make sure that we are at least the width of the button */
            theDialog.getContentPane().add(myMain);
            final Dimension myPrefSize = myMain.getPreferredSize();
            final int myWidth = Math.max(myPrefSize.width, pComponent.getWidth());
            myMain.setPreferredSize(new Dimension(myWidth, myPrefSize.height));

            /* Pack the dialog */
            theDialog.pack();
        }
    }

    /**
     * ScrollListener.
     */
    private class ArduinoScrollListener
            implements FocusListener, KeyListener {
        @Override
        public void focusGained(final FocusEvent e) {
            /* NoOp */
        }

        @Override
        public void focusLost(final FocusEvent e) {
            closeMenu();
        }

        @Override
        public void keyTyped(final KeyEvent e) {
            /* NoOp */
        }

        @Override
        public void keyPressed(final KeyEvent e) {
            switch (e.getKeyCode()) {
                case KeyEvent.VK_ESCAPE:
                    closeMenu();
                    break;
                case KeyEvent.VK_ENTER:
                    setSelectedItem(theActiveItem);
                    break;
                default:
                    break;
            }
        }

        @Override
        public void keyReleased(final KeyEvent e) {
            /* NoOp */
        }
    }

    /**
     * Menu element.
     */
    private class ArduinoScrollableMenuElement {
        /**
         * The label.
         */
        private final JLabel theLabel;

        /**
         * The item.
         */
        private final T theItem;

        /**
         * The base colour.
         */
        private Color theBaseColor;

        /**
         * Constructor.
         * @param pItem the Item
         */
        ArduinoScrollableMenuElement(final T pItem) {
            /* Store the parameters */
            theItem = pItem;

            /* Create the label */
            theLabel = new JLabel();
            theLabel.setOpaque(true);
            theLabel.setHorizontalAlignment(SwingConstants.LEFT);
            theLabel.setText(getText(pItem));
            theLabel.setMaximumSize(new Dimension(Integer.MAX_VALUE, DEFAULT_ROWHEIGHT));

            /* Listen for mouse events */
            theLabel.addMouseListener(new MouseAdapter() {
                @Override
                public void mouseEntered(final MouseEvent e) {
                    handleMouseEntered();
                    setActive(true);
                }

                @Override
                public void mouseExited(final MouseEvent e) {
                    setActive(false);
                }

                @Override
                public void mouseClicked(final MouseEvent e) {
                    setActive(false);
                    handleMouseClicked();
                }
            });
        }

        /**
         * Obtain the label.
         * @return the label
         */
        JComponent getComponent() {
            return theLabel;
        }

        /**
         * handle mouseEntered.
         */
        void handleMouseEntered() {
            theActiveItem = theItem;
        }

        /**
         * handle mouseClicked.
         */
        void handleMouseClicked() {
            setSelectedItem(theItem);
        }

        /**
         * Set the active indication.
         * @param pActive true/false
         */
        protected void setActive(final boolean pActive) {
            if (pActive) {
                if (theBaseColor == null) {
                    theBaseColor = theLabel.getBackground();
                    theLabel.setBackground(theHighlight);
                }
            } else if (theBaseColor != null) {
                theLabel.setBackground(theBaseColor);
                theBaseColor = null;
            }
        }
    }

    /**
     * Arrow Icons.
     */
    private enum ArduinoArrowIcon implements Icon {
        /**
         * Up Arrow.
         */
        UP(new Point(1, 9), new Point(5, 1), new Point(9, 9)),

        /**
         * Down Arrow.
         */
        DOWN(new Point(1, 1), new Point(5, 9), new Point(9, 1)),

        /**
         * Left Arrow.
         */
        LEFT(new Point(1, 5), new Point(9, 1), new Point(9, 9)),

        /**
         * Right Arrow.
         */
        RIGHT(new Point(1, 1), new Point(1, 9), new Point(9, 5));

        /**
         * The size of the icon.
         */
        private static final int ICON_SIZE = 10;

        /**
         * The number of points.
         */
        private final int theNumPoints;

        /**
         * X locations of points.
         */
        private final transient int[] theXPoints;

        /**
         * Y Locations of points.
         */
        private final transient int[] theYPoints;

        /**
         * Constructor.
         * @param pPoints the icon points
         */
        ArduinoArrowIcon(final Point... pPoints) {
            /* Allocate arrays */
            theNumPoints = pPoints.length;
            theXPoints = new int[theNumPoints];
            theYPoints = new int[theNumPoints];

            /* Loop through the points */
            for (int i = 0; i < theNumPoints; i++) {
                /* Store locations */
                theXPoints[i] = pPoints[i].x;
                theYPoints[i] = pPoints[i].y;
            }
        }

        @Override
        public void paintIcon(final Component c,
                              final Graphics g,
                              final int x,
                              final int y) {
            /* Allocate new graphics context */
            final Graphics g2 = g.create(x, y, ICON_SIZE, ICON_SIZE);

            /* Initialise graphics */
            g2.setColor(Color.GRAY);
            g2.drawPolygon(theXPoints, theYPoints, theNumPoints);

            /* If the component is enabled */
            if (c.isEnabled()) {
                /* Colour in the polygon */
                g2.setColor(Color.BLACK);
                g2.fillPolygon(theXPoints, theYPoints, theNumPoints);
            }

            /* Dispose of the graphics context */
            g2.dispose();
        }

        @Override
        public int getIconWidth() {
            return ICON_SIZE;
        }

        @Override
        public int getIconHeight() {
            return ICON_SIZE;
        }
    }
}