JSplitButton.java
/*
* IMPORTANT:
* This file is a derived work of the JSplitButton.java class from
* https://github.com/rhwood/jsplitbutton/tree/main (com.alexandriasoftware.swing.JSplitButton),
* which is licensed under the Apache License, Version 2.0.
* The original author is Naveed Quadri (2012) and Randall Wood (2016).
* Here the copy of the original license:
*
* Copyright (C) 2016, 2018 Randall Wood
*
* 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 swingtree.components;
import org.jspecify.annotations.Nullable;
import sprouts.Event;
import sprouts.Var;
import swingtree.UI;
import swingtree.components.action.ButtonClickedActionListener;
import swingtree.components.action.SplitButtonActionListener;
import swingtree.components.action.SplitButtonClickedActionListener;
import swingtree.style.StylableComponent;
import javax.swing.Icon;
import javax.swing.JButton;
import javax.swing.JPopupMenu;
import javax.swing.UIManager;
import javax.swing.plaf.ComponentUI;
import java.awt.*;
import java.awt.event.*;
import java.awt.image.BufferedImage;
import java.io.Serializable;
/**
* An implementation of a "split button" where the left (larger) side acts like a normal
* button and the right side down arrow based button opens an attached {@link JPopupMenu}.
* See {@link UI#splitButton(String)}, {@link UI#splitButton(Var)} and {@link UI#splitButton(Var, Event)}
* for usage in you UIs as well as the {@link swingtree.UIForSplitButton} for more in-depth
* configuration (like adding options to the split button for example).
* <p>
* This class raises two events:
* <ol>
* <li>{@link swingtree.components.action.SplitButtonActionListener#buttonClicked(java.awt.event.ActionEvent)}
* when the button is clicked</li>
* <li>{@link swingtree.components.action.SplitButtonActionListener#splitButtonClicked(java.awt.event.ActionEvent)}
* when the split part of the button is clicked</li>
* </ol>
* You can implement {@link swingtree.components.action.SplitButtonActionListener} to
* handle these events, however, it is advised to
* register events as part of the {@link swingtree.UIForSplitButton} API!
*
* @author Naveed Quadri 2012
* @author Randall Wood 2016
* @author Daniel Nepp 2023/2024
*/
public class JSplitButton extends JButton implements Serializable, StylableComponent {
/**
* Key used for serialization.
*/
private static final long serialVersionUID = 1L;
private int separatorSpacing = 4;
private int splitWidth = 22;
private int arrowSize = 8;
private boolean onSplit = false;
private Rectangle splitRectangle = new Rectangle();
private @Nullable JPopupMenu popupMenu;
private boolean alwaysPopup;
private Color arrowColor = Color.BLACK;
private Color disabledArrowColor = Color.GRAY;
private @Nullable Image image;
private @Nullable Image disabledImage;
private final swingtree.components.JSplitButton.Listener listener;
/**
* Creates a button with initial text and an icon.
*
* @param text the text of the button
* @param icon the Icon image to display on the button
*/
public JSplitButton( final String text, final @Nullable Icon icon ) {
super(text, icon);
this.listener = new swingtree.components.JSplitButton.Listener();
super.addMouseMotionListener(this.listener);
super.addMouseListener(this.listener);
super.addActionListener(this.listener);
UI.of(this).withStyle(delegate -> delegate.paddingRight(delegate.component().getSplitWidth()));
}
/**
* Creates a button with text.
*
* @param text the text of the button
*/
public JSplitButton(final String text) {
this(text, null);
}
/**
* Creates a button with an icon.
*
* @param icon the Icon image to display on the button
*/
public JSplitButton(final Icon icon) {
this("", icon);
}
/**
* Creates a button with no set text or icon.
*/
public JSplitButton() {
this("", null);
}
/** {@inheritDoc} */
@Override public void paint(Graphics g){
paintBackground(g, super::paint);
}
/** {@inheritDoc} */
@Override public void paintChildren(Graphics g) {
paintForeground(g, super::paintChildren);
}
/** {@inheritDoc} */
@Override public void setUISilently( ComponentUI ui ) { this.ui = ui; }
/**
* Returns the JPopupMenu if set, null otherwise.
*
* @return JPopupMenu
*/
public @Nullable JPopupMenu getPopupMenu() {
return popupMenu;
}
/**
* Sets the JPopupMenu to be displayed, when the split part of the button is
* clicked.
*
* @param popupMenu the menu to display
*/
public void setPopupMenu(final JPopupMenu popupMenu) {
this.popupMenu = popupMenu;
image = null; //to repaint the arrow image
}
/**
* Returns the separatorSpacing. Separator spacing is the space above and
* below the separator (the line drawn when you hover your mouse over the
* split part of the button).
*
* @return the spacing
*/
public int getSeparatorSpacing() {
return separatorSpacing;
}
/**
* Sets the separatorSpacing. Separator spacing is the space above and below
* the separator (the line drawn when you hover your mouse over the split
* part of the button).
*
* @param separatorSpacing the spacing
*/
public void setSeparatorSpacing(final int separatorSpacing) {
this.separatorSpacing = separatorSpacing;
}
/**
* Show the popup menu, if attached, even if the button part is clicked.
*
* @return true if alwaysPopup, false otherwise.
*/
public boolean isAlwaysPopup() {
return alwaysPopup;
}
/**
* Show the popup menu, if attached, even if the button part is clicked.
*
* @param alwaysPopup true to show the attached JPopupMenu even if the
* button part is clicked, false otherwise
*/
public void setAlwaysPopup(final boolean alwaysPopup) {
this.alwaysPopup = alwaysPopup;
}
/**
* Show the dropdown menu, if attached, even if the button part is clicked.
*
* @return true if alwaysDropdown, false otherwise.
* @deprecated use {@link #isAlwaysPopup() } instead.
*/
@Deprecated
public boolean isAlwaysDropDown() {
return alwaysPopup;
}
/**
* Show the dropdown menu, if attached, even if the button part is clicked.
*
* @param alwaysDropDown true to show the attached dropdown even if the
* button part is clicked, false otherwise
* @deprecated use {@link #setAlwaysPopup(boolean) } instead.
*/
@Deprecated
public void setAlwaysDropDown(final boolean alwaysDropDown) {
this.alwaysPopup = alwaysDropDown;
}
/**
* Gets the color of the arrow.
*
* @return the color of the arrow
*/
public Color getArrowColor() {
return arrowColor;
}
/**
* Set the arrow color.
*
* @param arrowColor the color of the arrow
*/
public void setArrowColor(final Color arrowColor) {
this.arrowColor = arrowColor;
image = null; // to repaint the image with the new color
}
/**
* Gets the disabled arrow color.
*
* @return color of the arrow if no popup menu is attached.
*/
public Color getDisabledArrowColor() {
return disabledArrowColor;
}
/**
* Sets the disabled arrow color.
*
* @param disabledArrowColor color of the arrow if no popup menu is
* attached.
*/
public void setDisabledArrowColor(final Color disabledArrowColor) {
this.disabledArrowColor = disabledArrowColor;
image = null; //to repaint the image with the new color
}
/**
* Splitwidth is the width of the split part of the button.
*
* @return the width of the split
*/
public int getSplitWidth() {
return splitWidth;
}
/**
* Splitwidth is the width of the split part of the button.
*
* @param splitWidth the width of the split
*/
public void setSplitWidth(final int splitWidth) {
this.splitWidth = splitWidth;
}
/**
* Gets the size of the arrow.
*
* @return size of the arrow
*/
public int getArrowSize() {
return arrowSize;
}
/**
* Sets the size of the arrow.
*
* @param arrowSize the size of the arrow
*/
public void setArrowSize(final int arrowSize) {
this.arrowSize = arrowSize;
image = null; //to repaint the image with the new size
}
/**
* Gets the image to be drawn in the split part. If no is set, a new image
* is created with the triangle.
*
* @return image
*/
public Image getImage() {
if (image != null) {
return image;
} else if (popupMenu == null) {
return this.getDisabledImage();
} else {
image = this.getImage(this.arrowColor);
return image;
}
}
/**
* Sets the image to draw instead of the triangle.
*
* @param image the image
*/
public void setImage(final Image image) {
this.image = image;
}
/**
* Gets the disabled image to be drawn in the split part. If no is set, a
* new image is created with the triangle.
*
* @return image
*/
public Image getDisabledImage() {
if (disabledImage != null) {
return disabledImage;
} else {
disabledImage = this.getImage(this.disabledArrowColor);
return disabledImage;
}
}
/**
* Draws the default arrow image in the specified color.
*
* @param color the color of the arrow
* @return image of the arrow
*/
private Image getImage(final Color color) {
final int size = _calculateArrowSize();
Graphics2D g;
BufferedImage img = new BufferedImage(size, size, BufferedImage.TYPE_INT_RGB);
g = img.createGraphics();
g.setColor(Color.WHITE);
g.fillRect(0, 0, img.getWidth(), img.getHeight());
g.setColor(color);
// this creates a triangle facing right >
g.fillPolygon(new int[]{0, 0, size / 2}, new int[]{0, size, size / 2}, 3);
g.dispose();
// rotate it to face downwards
img = rotate(img, 90);
BufferedImage dimg = new BufferedImage(img.getWidth(), img.getHeight(), BufferedImage.TYPE_INT_ARGB);
g = dimg.createGraphics();
g.setComposite(AlphaComposite.Src);
g.drawImage(img, null, 0, 0);
g.dispose();
for (int i = 0; i < dimg.getHeight(); i++) {
for (int j = 0; j < dimg.getWidth(); j++) {
if (dimg.getRGB(j, i) == Color.WHITE.getRGB()) {
dimg.setRGB(j, i, 0x8F1C1C);
}
}
}
return Toolkit.getDefaultToolkit().createImage(dimg.getSource());
}
/**
* Sets the disabled image to draw instead of the triangle.
*
* @param image the new image to use
*/
public void setDisabledImage(final Image image) {
this.disabledImage = image;
}
@Override
protected void paintComponent(final Graphics g) {
super.paintComponent(g);
Color oldColor = g.getColor();
int splitWidth = _calculateSplitWidth();
splitRectangle = new Rectangle(getWidth() - splitWidth, 0, splitWidth, getHeight());
g.translate(splitRectangle.x, splitRectangle.y);
int mh = getHeight() / 2;
int mw = splitWidth / 2;
int arrowSize = _calculateArrowSize();
g.drawImage((isEnabled() ? getImage() : getDisabledImage()), mw - arrowSize / 2, mh + 2 - arrowSize / 2, null);
if (onSplit && !alwaysPopup && popupMenu != null) {
int separatorSpacing = _calculateSeparatorSpacing();
g.setColor(UIManager.getLookAndFeelDefaults().getColor("Button.background"));
g.drawLine(1, separatorSpacing + 2, 1, getHeight() - separatorSpacing - 2);
g.setColor(UIManager.getLookAndFeelDefaults().getColor("Button.shadow"));
g.drawLine(2, separatorSpacing + 2, 2, getHeight() - separatorSpacing - 2);
}
g.setColor(oldColor);
g.translate(-splitRectangle.x, -splitRectangle.y);
}
private int _calculateArrowSize() {
return UI.scale(this.arrowSize);
}
private int _calculateSplitWidth() {
return UI.scale(this.splitWidth);
}
private int _calculateSeparatorSpacing() {
return UI.scale(this.separatorSpacing);
}
/**
* Rotates the given image with the specified angle.
*
* @param img image to rotate
* @param angle angle of rotation
* @return rotated image
*/
private BufferedImage rotate(final BufferedImage img, final int angle) {
int w = img.getWidth();
int h = img.getHeight();
BufferedImage dimg = new BufferedImage(w, h, img.getType());
Graphics2D g = dimg.createGraphics();
g.rotate(Math.toRadians(angle), w / 2f, h / 2f);
g.drawImage(img, null, 0, 0);
return dimg;
}
/**
* Adds an <code>SplitButtonActionListener</code> to the button.
*
* @param l the <code>ActionListener</code> to be added
* @deprecated Use
* {@link #addButtonClickedActionListener(swingtree.components.action.ButtonClickedActionListener)}
* or
* {@link #addSplitButtonClickedActionListener(swingtree.components.action.SplitButtonClickedActionListener)}
* instead.
*/
@Deprecated
public void addSplitButtonActionListener(final SplitButtonActionListener l) {
listenerList.add(SplitButtonActionListener.class, l);
}
/**
* Removes an <code>SplitButtonActionListener</code> from the button. If the
* listener is the currently set <code>Action</code> for the button, then
* the <code>Action</code> is set to <code>null</code>.
*
* @param l the listener to be removed
* @deprecated Use
* {@link #removeButtonClickedActionListener(swingtree.components.action.ButtonClickedActionListener)}
* or
* {@link #removeSplitButtonClickedActionListener(swingtree.components.action.SplitButtonClickedActionListener)}
* instead.
*/
@Deprecated
public void removeSplitButtonActionListener( final SplitButtonActionListener l ) {
if ((l != null) && (getAction() == l)) {
setAction(null);
} else {
listenerList.remove(SplitButtonActionListener.class, l);
}
}
/**
* Add a
* {@link swingtree.components.action.ButtonClickedActionListener}
* to the button. This listener will be notified whenever the button part is
* clicked.
*
* @param l the listener to add.
*/
public void addButtonClickedActionListener(final ButtonClickedActionListener l) {
listenerList.add(ButtonClickedActionListener.class, l);
}
/**
* Remove a
* {@link swingtree.components.action.ButtonClickedActionListener}
* from the button.
*
* @param l the listener to remove.
*/
public void removeButtonClickedActionListener(final ButtonClickedActionListener l) {
listenerList.remove(ButtonClickedActionListener.class, l);
}
/**
* Add a
* {@link swingtree.components.action.SplitButtonClickedActionListener}
* to the button. This listener will be notified whenever the split part is
* clicked.
*
* @param l the listener to add.
*/
public void addSplitButtonClickedActionListener(final SplitButtonClickedActionListener l) {
listenerList.add(SplitButtonClickedActionListener.class, l);
}
/**
* Remove a
* {@link swingtree.components.action.SplitButtonClickedActionListener}
* from the button.
*
* @param l the listener to remove.
*/
public void removeSplitButtonClickedActionListener(final SplitButtonClickedActionListener l) {
listenerList.remove(SplitButtonClickedActionListener.class, l);
}
/**
* Notifies all listeners that have registered interest for notification on
* this event type. The event instance is lazily created using the
* <code>event</code> parameter.
*
* @param event the <code>ActionEvent</code> object
* @see javax.swing.event.EventListenerList
*/
private void fireButtonClicked(final ActionEvent event) {
// Guaranteed to return a non-null array
SplitButtonActionListener[] splitButtonListeners = listenerList.getListeners(SplitButtonActionListener.class);
ButtonClickedActionListener[] buttonClickedListeners = listenerList.getListeners(ButtonClickedActionListener.class);
if (splitButtonListeners.length != 0 || buttonClickedListeners.length != 0) {
String actionCommand = event.getActionCommand();
if (actionCommand == null) {
actionCommand = getActionCommand();
}
ActionEvent e = new ActionEvent(swingtree.components.JSplitButton.this,
ActionEvent.ACTION_PERFORMED,
actionCommand,
event.getWhen(),
event.getModifiers());
// Process the listeners last to first
if (splitButtonListeners.length != 0) {
for (int i = splitButtonListeners.length - 1; i >= 0; i--) {
splitButtonListeners[i].buttonClicked(e);
}
}
if (buttonClickedListeners.length != 0) {
for (int i = buttonClickedListeners.length - 1; i >= 0; i--) {
buttonClickedListeners[i].actionPerformed(e);
}
}
}
}
/**
* Notifies all listeners that have registered interest for notification on
* this event type. The event instance is lazily created using the
* <code>event</code> parameter.
*
* @param event the <code>ActionEvent</code> object
* @see javax.swing.event.EventListenerList
*/
private void fireSplitButtonClicked(final ActionEvent event) {
// Guaranteed to return a non-null array
SplitButtonActionListener[] splitButtonListeners = listenerList.getListeners(SplitButtonActionListener.class);
SplitButtonClickedActionListener[] buttonClickedListeners = listenerList.getListeners(SplitButtonClickedActionListener.class);
if (splitButtonListeners.length != 0 || buttonClickedListeners.length != 0) {
String actionCommand = event.getActionCommand();
if (actionCommand == null) {
actionCommand = getActionCommand();
}
ActionEvent e = new ActionEvent(swingtree.components.JSplitButton.this,
ActionEvent.ACTION_PERFORMED,
actionCommand,
event.getWhen(),
event.getModifiers());
// Process the listeners last to first
if (splitButtonListeners.length != 0) {
for (int i = splitButtonListeners.length - 1; i >= 0; i--) {
splitButtonListeners[i].splitButtonClicked(e);
}
}
if (buttonClickedListeners.length != 0) {
for (int i = buttonClickedListeners.length - 1; i >= 0; i--) {
buttonClickedListeners[i].actionPerformed(e);
}
}
}
}
/**
* Returns the {@link swingtree.components.JSplitButton.Listener},
* which is used to handle internal changes within the JSplitButton itself.
* @return the listener
*/
swingtree.components.JSplitButton.Listener getListener() {
return listener;
}
/**
* Listener for internal changes within the JSplitButton itself.
*
* Package private so its available to tests.
*/
class Listener implements MouseMotionListener, MouseListener, ActionListener {
@Override
public void actionPerformed(final ActionEvent e) {
if (popupMenu == null) {
fireButtonClicked(e);
} else if (alwaysPopup) {
popupMenu.show(swingtree.components.JSplitButton.this, getWidth() - (int) popupMenu.getPreferredSize().getWidth(), getHeight());
fireButtonClicked(e);
} else if (onSplit) {
popupMenu.show(swingtree.components.JSplitButton.this, getWidth() - (int) popupMenu.getPreferredSize().getWidth(), getHeight());
fireSplitButtonClicked(e);
} else {
fireButtonClicked(e);
}
}
@Override
public void mouseExited(final MouseEvent e) {
onSplit = false;
repaint(splitRectangle);
}
@Override
public void mouseMoved(final MouseEvent e) {
onSplit = splitRectangle.contains(e.getPoint());
repaint(splitRectangle);
}
// <editor-fold defaultstate="collapsed" desc="Unused Listeners">
@Override
public void mouseDragged(final MouseEvent e) {
}
@Override
public void mouseClicked(final MouseEvent e) {
}
@Override
public void mousePressed(final MouseEvent e) {
}
@Override
public void mouseReleased(final MouseEvent e) {
}
@Override
public void mouseEntered(final MouseEvent e) {
}
// </editor-fold>
}
}