DnDTabbedPane.java

package com.jsql.view.swing.tab.dnd;

import com.jsql.view.swing.action.ActionCloseTabResult;
import com.jsql.view.swing.ui.CustomMetalTabbedPaneUI;
import com.jsql.view.swing.util.UiUtil;

import javax.swing.*;
import java.awt.*;
import java.awt.dnd.DragSource;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.util.Objects;
import java.util.Optional;

public class DnDTabbedPane extends JTabbedPane {
    
    private static final int SCROLL_SIZE = 20;  // Test
    private static final int BUTTON_SIZE = 30;  // 30 is magic number of scroll button size
    private static final int LINE_WIDTH = 3;
    private static final Rectangle RECT_BACKWARD = new Rectangle();
    private static final Rectangle RECT_FORWARD = new Rectangle();
    protected static final Rectangle RECT_LINE = new Rectangle();
    protected int dragTabIndex = -1;
    private transient DnDDropLocation dropLocation;

    public static final class DnDDropLocation extends TransferHandler.DropLocation {
        
        private final int index;
        private boolean dropable = true;
        
        private DnDDropLocation(Point p, int index) {
            super(p);
            this.index = index;
        }
        
        public int getIndex() {
            return this.index;
        }
        
        public void setDroppable(boolean flag) {
            this.dropable = flag;
        }
        
        public boolean isDroppable() {
            return this.dropable;
        }
    }
    
    private void clickArrowButton(String actionKey) {
        
        JButton scrollForwardButton = null;
        JButton scrollBackwardButton = null;
        
        for (Component c: this.getComponents()) {
            if (c instanceof JButton) {
                if (scrollForwardButton == null) {
                    scrollForwardButton = (JButton) c;
                } else if (scrollBackwardButton == null) {
                    scrollBackwardButton = (JButton) c;
                }
            }
        }
        
        JButton button = "scrollTabsForwardAction".equals(actionKey) ? scrollForwardButton : scrollBackwardButton;
    
        Optional.ofNullable(button)
            .filter(JButton::isEnabled)
            .ifPresent(JButton::doClick);
    }
    
    public void autoScrollTest(Point pt) {
        
        Rectangle r = this.getTabAreaBounds();
        
        if (isTopBottomTabPlacement(this.getTabPlacement())) {
            
            RECT_BACKWARD.setBounds(r.x, r.y, SCROLL_SIZE, r.height);
            RECT_FORWARD.setBounds(r.x + r.width - SCROLL_SIZE - BUTTON_SIZE, r.y, SCROLL_SIZE + BUTTON_SIZE, r.height);
            
        } else {
            
            RECT_BACKWARD.setBounds(r.x, r.y, r.width, SCROLL_SIZE);
            RECT_FORWARD.setBounds(r.x, r.y + r.height - SCROLL_SIZE - BUTTON_SIZE, r.width, SCROLL_SIZE + BUTTON_SIZE);
        }
        
        if (RECT_BACKWARD.contains(pt)) {
            this.clickArrowButton("scrollTabsBackwardAction");
        } else if (RECT_FORWARD.contains(pt)) {
            this.clickArrowButton("scrollTabsForwardAction");
        }
    }
    
    protected DnDTabbedPane() {
        
        super();
        
        // UIManager.put() is not enough
        this.setUI(new CustomMetalTabbedPaneUI());
        this.setBorder(BorderFactory.createMatteBorder(0, 1, 0, 0, UiUtil.COLOR_COMPONENT_BORDER));
        
        var h = new Handler();
        this.addMouseListener(h);
        this.addMouseMotionListener(h);
        this.addPropertyChangeListener(h);
    }
    
    public DnDDropLocation dropLocationForPointDnD(Point p) {
        
        for (var i = 0; i < this.getTabCount(); i++) {
            if (this.getBoundsAt(i).contains(p)) {
                return new DnDDropLocation(p, i);
            }
        }
        
        if (this.getTabAreaBounds().contains(p)) {
            return new DnDDropLocation(p, this.getTabCount());
        }
        
        return new DnDDropLocation(p, -1);
    }
    
    public void setDropLocation(TransferHandler.DropLocation location, boolean forDrop) {
        
        DnDDropLocation old = this.dropLocation;
        
        if (Objects.isNull(location) || !forDrop) {
            this.dropLocation = new DnDDropLocation(new Point(), -1);
        } else if (location instanceof DnDDropLocation) {
            this.dropLocation = (DnDDropLocation) location;
        }
        
        this.firePropertyChange("dropLocation", old, this.dropLocation);
    }
    
    public void exportTab(int dragIndex, JTabbedPane target, int targetIndex) {
        
        var cmp = this.getComponentAt(dragIndex);
        var tab = this.getTabComponentAt(dragIndex);
        String title = this.getTitleAt(dragIndex);
        var icon = this.getIconAt(dragIndex);
        String tip = this.getToolTipTextAt(dragIndex);
        boolean isEnabled = this.isEnabledAt(dragIndex);
        
        this.remove(dragIndex);
        target.insertTab(title, icon, cmp, tip, targetIndex);
        target.setEnabledAt(targetIndex, isEnabled);

        target.setTabComponentAt(targetIndex, tab);
        target.setSelectedIndex(targetIndex);
        
        if (tab instanceof JComponent) {
            ((JComponent) tab).scrollRectToVisible(tab.getBounds());
        }
    }
    
    public void convertTab(int prev, int next) {
        
        var cmp = this.getComponentAt(prev);
        var tab = this.getTabComponentAt(prev);
        String title = this.getTitleAt(prev);
        var icon = this.getIconAt(prev);
        String tip = this.getToolTipTextAt(prev);
        boolean isEnabled = this.isEnabledAt(prev);
        int tgtindex = prev > next ? next : next - 1;
        
        this.remove(prev);
        this.insertTab(title, icon, cmp, tip, tgtindex);
        this.setEnabledAt(tgtindex, isEnabled);
        
        // When you drag'n'drop a disabled tab, it finishes enabled and selected.
        // pointed out by dlorde
        if (isEnabled) {
            this.setSelectedIndex(tgtindex);
        }
        
        // I have a component in all tabs (jlabel with an X to close the tab) and when i move a tab the component disappear.
        // pointed out by Daniel Dario Morales Salas
        this.setTabComponentAt(tgtindex, tab);
    }
    
    public Optional<Rectangle> getDropLineRect() {
        
        int index = Optional.ofNullable(this.getDropLocation())
            .filter(DnDDropLocation::isDroppable)
            .map(DnDDropLocation::getIndex)
            .orElse(-1);
        
        if (index < 0) {
            
            RECT_LINE.setBounds(0, 0, 0, 0);
            return Optional.empty();
        }
        
        int a = Math.min(index, 1);
        Rectangle r = this.getBoundsAt(a * (index - 1));
        
        if (isTopBottomTabPlacement(this.getTabPlacement())) {
            RECT_LINE.setBounds(r.x - LINE_WIDTH / 2 + r.width * a, r.y, LINE_WIDTH, r.height);
        } else {
            RECT_LINE.setBounds(r.x, r.y - LINE_WIDTH / 2 + r.height * a, r.width, LINE_WIDTH);
        }
        
        return Optional.of(RECT_LINE);
    }
    
    public Rectangle getTabAreaBounds() {
        
        Rectangle tabbedRect = this.getBounds();
        int xx = tabbedRect.x;
        int yy = tabbedRect.y;
        
        Rectangle compRect = Optional.ofNullable(this.getSelectedComponent())
            .map(Component::getBounds)
            .orElseGet(Rectangle::new);

        int tabPlacement = this.getTabPlacement();
        
        if (isTopBottomTabPlacement(tabPlacement)) {
            
            tabbedRect.height = tabbedRect.height - compRect.height;
            
            if (tabPlacement == BOTTOM) {
                tabbedRect.y += compRect.y + compRect.height;
            }
        } else {
            
            tabbedRect.width = tabbedRect.width - compRect.width;
            
            if (tabPlacement == RIGHT) {
                tabbedRect.x += compRect.x + compRect.width;
            }
        }
        
        tabbedRect.translate(-xx, -yy);
        
        return tabbedRect;
    }

    public static boolean isTopBottomTabPlacement(int tabPlacement) {
        return tabPlacement == SwingConstants.TOP || tabPlacement == SwingConstants.BOTTOM;
    }

    private class Handler extends MouseAdapter implements PropertyChangeListener { // , BeforeDrag
        
        private Point startPt;
        private final int gestureMotionThreshold = DragSource.getDragThreshold();

        private void repaintDropLocation() {
            
            Component c = DnDTabbedPane.this.getRootPane().getGlassPane();
            
            if (c instanceof GhostGlassPane) {
                
                GhostGlassPane glassPane = (GhostGlassPane) c;
                glassPane.setTargetTabbedPane(DnDTabbedPane.this);
                glassPane.repaint();
            }
        }
        
        // PropertyChangeListener
        @Override
        public void propertyChange(PropertyChangeEvent e) {
            
            String propertyName = e.getPropertyName();
            if ("dropLocation".equals(propertyName)) {
                this.repaintDropLocation();
            }
        }
        
        // MouseListener
        @Override
        public void mousePressed(MouseEvent e) {
            
            DnDTabbedPane src = (DnDTabbedPane) e.getComponent();
            boolean isOnlyOneTab = src.getTabCount() <= 1;
            
            if (isOnlyOneTab) {
                
                this.startPt = null;
                return;
            }
            
            var tabPt = e.getPoint();
            int idx = src.indexAtLocation(tabPt.x, tabPt.y);
            
            // disabled tab, null component problem.
            // pointed out by daryl. NullPointerException: i.e. addTab("Tab", null)
            boolean flag = idx < 0 || !src.isEnabledAt(idx) || Objects.isNull(src.getComponentAt(idx));
            
            this.startPt = flag ? null : tabPt;
        }
        
        @Override
        public void mouseDragged(MouseEvent e) {
            
            var tabPt = e.getPoint();
            
            if (Objects.nonNull(this.startPt) && this.startPt.distance(tabPt) > this.gestureMotionThreshold) {
                
                DnDTabbedPane src = (DnDTabbedPane) e.getComponent();
                var th = src.getTransferHandler();
                DnDTabbedPane.this.dragTabIndex = src.indexAtLocation(tabPt.x, tabPt.y);
                
                // Unhandled NoClassDefFoundError #56620: Could not initialize class java.awt.dnd.DragSource
                th.exportAsDrag(src, e, TransferHandler.MOVE);
                
                RECT_LINE.setBounds(0, 0, 0, 0);
                src.getRootPane().getGlassPane().setVisible(true);
                src.setDropLocation(new DnDDropLocation(tabPt, -1), true);
                
                this.startPt = null;
            }
        }

        @Override
        public void mouseClicked(MouseEvent e) {
            
            var tabPt = e.getPoint();
            JTabbedPane src = (JTabbedPane) e.getSource();
            
            int i = src.indexAtLocation(tabPt.x, tabPt.y);
            
            if (-1 < i && e.getButton() == MouseEvent.BUTTON2) {
                ActionCloseTabResult.perform(i);
            }
        }
    }
    
    public final DnDDropLocation getDropLocation() {
        return this.dropLocation;
    }
}