EnterExitComponentBoundsEventDispatcher.java

package swingtree;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import swingtree.style.ComponentExtension;

import javax.swing.JComponent;
import javax.swing.SwingUtilities;
import java.awt.*;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.MouseListener;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.WeakHashMap;

/**
 *  A custom event dispatcher for mouse enter and exit events based on the mouse
 *  cursor location exiting or entering a component's bounds even if the
 *  mouse cursor is on a child component.<br>
 *  This is in essence a fix for the default Swing behavior which also dispatches enter/exit
 *  events when the mouse cursor enters or exits the bounds of a child component
 *  which also has a listener for these events. So using this we try to
 *  make the behavior more predictable, reliable and intuitive.
 */
final class EnterExitComponentBoundsEventDispatcher {

    private static final Logger log = LoggerFactory.getLogger(EnterExitComponentBoundsEventDispatcher.class);
    private static final EnterExitComponentBoundsEventDispatcher eventDispatcher = new EnterExitComponentBoundsEventDispatcher();
    private static final MouseListener dispatcherListener = new MouseAdapter() { };

    static void addMouseEnterListener(UI.ComponentArea area, Component component, MouseListener listener) {
        Map<Component, ComponentEnterExitListeners[]> allListeners = eventDispatcher.listeners;
        ComponentEnterExitListeners listeners = allListeners.computeIfAbsent(component, EnterExitComponentBoundsEventDispatcher::iniListeners)[area.ordinal()];
        listeners.addEnterListener(listener);
    }

    static void addMouseExitListener(UI.ComponentArea area, Component component, MouseListener listener) {
        Map<Component, ComponentEnterExitListeners[]> allListeners = eventDispatcher.listeners;
        ComponentEnterExitListeners listeners = allListeners.computeIfAbsent(component, EnterExitComponentBoundsEventDispatcher::iniListeners)[area.ordinal()];
        listeners.addExitListener(listener);
    }

    private static ComponentEnterExitListeners[] iniListeners(Component component) {
        // ensures that mouse events are enabled
        component.addMouseListener(dispatcherListener);
        ComponentEnterExitListeners[] listenerArray = new ComponentEnterExitListeners[UI.ComponentArea.values().length];
        for (UI.ComponentArea a : UI.ComponentArea.values()) {
            listenerArray[a.ordinal()] = new ComponentEnterExitListeners(a, component);
        }
        return listenerArray;
    }


    private final Map<Component, ComponentEnterExitListeners[]> listeners;

    private EnterExitComponentBoundsEventDispatcher() {
        listeners = new WeakHashMap<>();
        Toolkit.getDefaultToolkit().addAWTEventListener(this::onMouseEvent, AWTEvent.MOUSE_EVENT_MASK);
        Toolkit.getDefaultToolkit().addAWTEventListener(this::onMouseEvent, AWTEvent.MOUSE_MOTION_EVENT_MASK);
    }

    public void onMouseEvent(AWTEvent event) {
        if (event instanceof MouseEvent) {
            MouseEvent mouseEvent = (MouseEvent) event;

            Component c = mouseEvent.getComponent();
            while (c != null) {
                ComponentEnterExitListeners[] componentListenerInfo = listeners.get(c);
                if (componentListenerInfo != null) {
                    for (UI.ComponentArea area : UI.ComponentArea.values()) {
                        ComponentEnterExitListeners currentListeners = componentListenerInfo[area.ordinal()];
                        currentListeners.dispatch(c, mouseEvent);
                    }
                }
                c = c.getParent();
            }
        }
    }

    enum Location {
        INSIDE, OUTSIDE
    }

    /**
     *  Contains the enter and exit listeners for a component as well as
     *  a flag indicating whether the mouse cursor is currently within the
     *  bounds of the component or not.
     */
    private static class ComponentEnterExitListeners {
        private final UI.ComponentArea area;
        private Location location;
        private final List<MouseListener> enterListeners;
        private final List<MouseListener> exitListeners;

        public ComponentEnterExitListeners( UI.ComponentArea area, Component component ) {
            this.area = area;
            this.location = Location.OUTSIDE;
            this.enterListeners = new ArrayList<>();
            this.exitListeners  = new ArrayList<>();
        }

        private final boolean areaIsEqualToBounds(Component component) {
            if ( this.area == UI.ComponentArea.ALL ) {
                return true;
            }
            Shape shape = ComponentExtension.from((JComponent) component).getComponentArea(area).orElse(null);
            return shape != null && shape.equals(component.getBounds());
        }

        public void addEnterListener(MouseListener listener) {
            enterListeners.add(listener);
        }

        public void addExitListener(MouseListener listener) {
            exitListeners.add(listener);
        }

        public void dispatch(Component component, MouseEvent event) {
            assert isRelated(component, event.getComponent());

            switch (event.getID()) {
                case MouseEvent.MOUSE_ENTERED:
                    if (location == Location.INSIDE)
                        return;

                    location = onMouseEnter(component, event);
                    break;
                case MouseEvent.MOUSE_EXITED:
                    if (location == Location.OUTSIDE)
                        return;

                    if (containsScreenLocation(component, event.getLocationOnScreen()))
                        return;

                    location = onMouseExit(component, event);
                    break;
                case MouseEvent.MOUSE_MOVED:
                    if ( !areaIsEqualToBounds(component) ) {
                        if ( location == Location.INSIDE ) {
                            location = onMouseExit(component, event);
                        } else if ( location == Location.OUTSIDE ) {
                            location = onMouseEnter(component, event);
                        }
                    }
                break;
            }
        }

        private Location determineCurrentLocationOf(MouseEvent event) {
            return ComponentExtension.from((JComponent) event.getComponent())
                    .getComponentArea(area)
                    .filter( shape -> shape.contains(event.getPoint()) )
                    .map( isInsideShape -> Location.INSIDE )
                    .orElse(Location.OUTSIDE);
        }

        private Location onMouseEnter(Component component, MouseEvent mouseEvent)
        {
            mouseEvent = withNewSource(mouseEvent, component);

            if (enterListeners.isEmpty())
                return determineCurrentLocationOf(mouseEvent);

            if ( areaIsEqualToBounds(component) ) {
                dispatchEnterToAllListeners(mouseEvent);
                return Location.INSIDE;
            } else {
                Location nextLocation = determineCurrentLocationOf(mouseEvent);
                if ( nextLocation == Location.INSIDE && this.location == Location.OUTSIDE )
                    dispatchEnterToAllListeners(mouseEvent);

                return nextLocation;
            }
        }

        private void dispatchEnterToAllListeners(MouseEvent mouseEvent) {
            for ( MouseListener listener : enterListeners ) {
                try {
                    listener.mouseEntered(mouseEvent);
                } catch (Exception e) {
                    log.error("Failed to process mouseEntered event {}. Error: {}", mouseEvent, e.getMessage(), e);
                }
            }
        }

        private Location onMouseExit(Component component, MouseEvent mouseEvent)
        {
            mouseEvent = withNewSource(mouseEvent, component);

            if (exitListeners.isEmpty())
                return determineCurrentLocationOf(mouseEvent);

            if ( areaIsEqualToBounds(component) ) {
                dispatchExitToAllListeners(mouseEvent);
                return Location.OUTSIDE;
            } else {
                Location nextLocation = determineCurrentLocationOf(mouseEvent);
                if ( nextLocation == Location.OUTSIDE && this.location == Location.INSIDE )
                    dispatchExitToAllListeners(mouseEvent);

                return nextLocation;
            }
        }

        private void dispatchExitToAllListeners(MouseEvent mouseEvent) {
            for ( MouseListener listener : exitListeners ) {
                try {
                    listener.mouseExited(mouseEvent);
                } catch (Exception e) {
                    log.error("Failed to process mouseExited event {}. Error: {}", mouseEvent, e.getMessage(), e);
                }
            }
        }
    }

    private static boolean isRelated(Component parent, Component other) {
        Component o = other;
        while (o != null) {
            if (o == parent)
                return true;
            o = o.getParent();
        }
        return false;
    }

    private static boolean containsScreenLocation(Component component, Point screenLocation) {
        if (!component.isShowing())
            return false;
        Point compLocation = component.getLocationOnScreen();
        Dimension compSize = component.getSize();
        int relativeX = screenLocation.x - compLocation.x;
        int relativeY = screenLocation.y - compLocation.y;
        return (relativeX >= 0 && relativeX < compSize.width && relativeY >= 0 && relativeY < compSize.height);
    }

    private static MouseEvent withNewSource(MouseEvent event, Component newSource) {
        if ( event.getSource() == newSource )
            return event;

        // We need to convert the mouse position to the new source's coordinate system
        Point mousePositionRelativeToNewComponent = event.getPoint();
        SwingUtilities.convertPointToScreen(mousePositionRelativeToNewComponent, (Component) event.getSource());
        SwingUtilities.convertPointFromScreen(mousePositionRelativeToNewComponent, newSource);
        return new MouseEvent(
            newSource,
            event.getID(),
            event.getWhen(),
            event.getModifiersEx(),
            mousePositionRelativeToNewComponent.x,
            mousePositionRelativeToNewComponent.y,
            event.getClickCount(),
            event.isPopupTrigger(),
            event.getButton()
        );
    }

}