/*
 *    GeoTools - OpenSource mapping toolkit
 *    http://geotools.org
 *    (C) 2003-2006, Geotools Project Managment Committee (PMC)
 *    (C) 2001, Institut de Recherche pour le Développement
 *
 *    This library is free software; you can redistribute it and/or
 *    modify it under the terms of the GNU Lesser General Public
 *    License as published by the Free Software Foundation; either
 *    version 2.1 of the License, or (at your option) any later version.
 *
 *    This library is distributed in the hope that it will be useful,
 *    but WITHOUT ANY WARRANTY; without even the implied warranty of
 *    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
 *    Lesser General Public License for more details.
 */
package org.geotools.gui.swing;

// Geometry
import java.awt.Shape;
import java.awt.Rectangle;
import java.awt.geom.Line2D;
import java.awt.geom.Point2D;
import java.awt.geom.Ellipse2D;
import java.awt.geom.Rectangle2D;
import java.awt.geom.RoundRectangle2D;
import java.awt.geom.RectangularShape;
import java.awt.geom.AffineTransform;
import java.awt.geom.NoninvertibleTransformException;

// Graphics
import java.awt.Color;
import java.awt.Component;
import java.awt.Graphics2D;

// Events
import java.awt.event.MouseEvent;
import javax.swing.event.MouseInputAdapter;


/**
 * Controller which allows the user to select a region of a component. The user must click on a
 * point in the component, then drag the mouse pointer whilst keeping the button pressed. During
 * the dragging, the shape which is drawn will normally be a rectangle.  Other shapes could always
 * be used such as, for example, an ellipse. To use this class, it is necessary to create a derived
 * class which defines the following methods:
 *
 * <ul>
 *   <li>{@link #selectionPerformed} (obligatory)</li>
 *   <li>{@link #getModel} (optional)</li>
 * </ul>
 *
 * This controller should then be registered with one, and only one, component
 * using the following syntax:
 *
 * <blockquote><pre>
 * {@link Component} component=...
 * MouseSelectionTracker control=...
 * component.addMouseListener(control);
 * </pre></blockquote>
 *
 * @since 2.0
 * @source $URL: http://svn.geotools.org/geotools/tags/2.4.1/modules/extension/widgets-swing/src/main/java/org/geotools/gui/swing/MouseSelectionTracker.java $
 * @version $Id: MouseSelectionTracker.java 22482 2006-10-31 02:58:00Z desruisseaux $
 * @author Martin Desruisseaux
 */
abstract class MouseSelectionTracker extends MouseInputAdapter {
    /**
     * Stippled rectangle representing the region which the user is currently
     * selecting.  This rectangle can be empty.  These coordinates are only
     * significant in the period between the user pressing the mouse button
     * and then releasing it to outline a region. Conventionally, the
     * {@code null} value indicates that a line should be used instead of
     * a rectangular shape.  The coordinates are always expressed in pixels.
     */
    private transient RectangularShape mouseSelectedArea;

    /**
     * Colour to replace during XOR drawings on a graphic.
     * This colour is specified in {@link Graphics2D#setColor}.
     */
    private Color backXORColor = Color.white;

    /**
     * Colour to replace with during the XOR drawings on a graphic.
     * This colour is specified in {@link Graphics2D#setXORMode}.
     */
    private Color lineXORColor = Color.black;

    /**
     * <var>x</var> coordinate of the mouse when the button is pressed.
     */
    private transient int ox;

    /**
     * <var>y</var> coordinate of the mouse when the button is pressed.
     */
    private transient int oy;

    /**
     * <var>x</var> coordinate of the mouse during the last drag.
     */
    private transient int px;

    /**
     * <var>y</var> coordinate of the mouse during the last drag.
     */
    private transient int py;

    /**
     * Indicates whether a selection is underway.
     */
    private transient boolean isDragging;

    /**
     * Constructs an object which will allow rectangular regions to be selected using the mouse.
     */
    public MouseSelectionTracker() {
    }

    /**
     * Specifies the colours to be used for drawing the outline of a box when
     * the user selects a region.  All {@code a} colours will be replaced
     * by {@code b} colours and vice versa.
     */
    public void setXORColors(final Color a, final Color b) {
        backXORColor = a;
        lineXORColor = b;
    }

    /**
     * Returns the geometric shape to use for marking the boundaries of a region.  This shape is
     * normally a rectangle but could also be an ellipse, an arrow or even other shapes. The
     * coordinates of the returned shape will not be taken into account. In fact, these coordinates
     * will regularly be discarded. Only the class of the returned shape will count (for example,
     * {@link java.awt.geom.Ellipse2D} vs {@link java.awt.geom.Rectangle2D}) and their parameters
     * which are not linked to their position (for example, the rounding of a rectangle's
     * corners).
     * <p>
     * The shape returned will normally be from a class derived from {@link RectangularShape},
     * but could also be from the {@link Line2D} class. <strong>Any other class risks throwing a
     * {@link ClassCastException} when executed</strong>.
     *
     * The default implementation always returns an object {@link Rectangle}.
     *
     * @param  event Mouse coordinate when the button is pressed.  This information can be used by
     *         the derived classes which like to be informed of the position of the mouse before
     *         chosing a geometric shape.
     * @return Shape from the class {link RectangularShape} or {link Line2D}, or {@code null}
     *         to indicate that we do not want to make a selection.
     */
    protected Shape getModel(final MouseEvent event) {
        return new Rectangle();
    }

    /**
     * Method which is automatically called after the user selects a region with the mouse.
     * All coordinates passed in as parameters are expressed in pixels.
     *
     * @param ox <var>x</var> coordinate of the mouse when the user pressed the mouse button.
     * @param oy <var>y</var> coordinate of the mouse when the user pressed the mouse button.
     * @param px <var>x</var> coordinate of the mouse when the user released the mouse button.
     * @param py <var>y</var> coordinate of the mouse when the user released the mouse button.
     */
    protected abstract void selectionPerformed(int ox, int oy, int px, int py);

    /**
     * Returns the geometric shape surrounding the last region to be selected by the user. An
     * optional affine transform can be specified to convert the region selected by the user
     * into logical coordinates. The class of the shape returned depends on the model returned by
     * {@link #getModel}:
     *
     * <ul>
     *   <li>If the model is null (which means that this {@code MouseSelectionTracker} object only
     *       draws a line between points), the object returned will belong to the {@link Line2D}
     *       class.</li>
     *   <li>If the model is not null, the object returned can be from the same class (most often
     *       {@link java.awt.geom.Rectangle2D}). There could always be situations where the object
     *       returned is from another class, for example if the affine transform carries out a
     *       rotation.</li>
     * </ul>
     *
     * @param  transform Affine transform which converts logical coordinates into pixel coordinates.
     *         It is usually an affine transform which is used in a {@code paint(...)} method to
     *         draw shapes expressed in logical coordinates.
     * @return A geometric shape enclosing the last region to be selected by the user, or
     *         {@code null} if no selection has yet been made.
     * @throws NoninvertibleTransformException If the affine transform can't be inverted.
     */
    public Shape getSelectedArea(final AffineTransform transform) throws NoninvertibleTransformException {
        if (ox == px && oy == py) {
            return null;
        }
        RectangularShape shape = mouseSelectedArea;
        if (transform != null && !transform.isIdentity()) {
            if (shape == null) {
                final Point2D.Float po = new Point2D.Float(ox, oy);
                final Point2D.Float pp = new Point2D.Float(px, py);
                transform.inverseTransform(po, po);
                transform.inverseTransform(pp, pp);
                return new Line2D.Float(po, pp);
            } else {
                if (canReshape(shape, transform)) {
                    final Point2D.Double point = new Point2D.Double();
                    double xmin = Double.POSITIVE_INFINITY;
                    double ymin = Double.POSITIVE_INFINITY;
                    double xmax = Double.NEGATIVE_INFINITY;
                    double ymax = Double.NEGATIVE_INFINITY;
                    for (int i = 0; i < 4; i++) {
                        point.x = (i&1) == 0 ? shape.getMinX() : shape.getMaxX();
                        point.y = (i&2) == 0 ? shape.getMinY() : shape.getMaxY();
                        transform.inverseTransform(point, point);
                        if (point.x < xmin) xmin = point.x;
                        if (point.x > xmax) xmax = point.x;
                        if (point.y < ymin) ymin = point.y;
                        if (point.y > ymax) ymax = point.y;
                    }
                    if (shape instanceof Rectangle) {
                        return new Rectangle2D.Float((float) xmin,
                                                     (float) ymin,
                                                     (float) (xmax - xmin),
                                                     (float) (ymax - ymin));
                    } else {
                        shape = (RectangularShape) shape.clone();
                        shape.setFrame(xmin, ymin, xmax - xmin, ymax - ymin);
                        return shape;
                    }
                }
                else {
                    return transform.createInverse().createTransformedShape(shape);
                }
            }
        }
        else {
            return (shape != null) ? (Shape) shape.clone() : new Line2D.Float(ox, oy, px, py);
        }
    }

    /**
     * Indicates whether we can transform {@code shape} simply by calling its
     * {@code shape.setFrame(...)} method rather than by using the heavy artillery
     * that is the {@code transform.createTransformedShape(shape)} method.
     */
    private static boolean canReshape(final RectangularShape shape,
                                      final AffineTransform transform) {
        final int type=transform.getType();
        if ((type & AffineTransform.TYPE_GENERAL_TRANSFORM) != 0) return false;
        if ((type & AffineTransform.TYPE_MASK_ROTATION)     != 0) return false;
        if ((type & AffineTransform.TYPE_FLIP)              != 0) {
            if (shape instanceof Rectangle2D)      return true;
            if (shape instanceof Ellipse2D)        return true;
            if (shape instanceof RoundRectangle2D) return true;
            return false;
        }
        return true;
    }

    /**
     * Returns a {@link Graphics2D} object to be used for drawing in the specified component. We
     * must not forget to call {@link Graphics2D#dispose} when the graphics object is no longer
     * needed.
     */
    private Graphics2D getGraphics(final Component c) {
        final Graphics2D graphics = (Graphics2D) c.getGraphics();
        graphics.setXORMode(lineXORColor);
        graphics.setColor  (backXORColor);
        return graphics;
    }

    /**
     * Informs this controller that the mouse button has been pressed.
     * The default implementation retains the mouse coordinate (which will
     * become one of the corners of the future rectangle to be drawn) 
     * and prepares {@code this} to observe the mouse movements.
     *
     * @throws ClassCastException if {@link #getModel} doesn't return a shape
     *         from the class {link RectangularShape} or {link Line2D}.
     */
    public void mousePressed(final MouseEvent event) throws ClassCastException {
        if (!event.isConsumed() && (event.getModifiers() & MouseEvent.BUTTON1_MASK) != 0) {
            final Component source = event.getComponent();
            if (source != null) {
                Shape model = getModel(event);
                if (model != null) {
                    isDragging = true;
                    ox = px = event.getX();
                    oy = py = event.getY();
                    if (model instanceof Line2D) {
                        model = null;
                    }
                    mouseSelectedArea = (RectangularShape) model;
                    if (mouseSelectedArea != null) {
                        mouseSelectedArea.setFrame(ox, oy, 0, 0);
                    }
                    source.addMouseMotionListener(this);
                }
                source.requestFocus();
                event.consume();
            }
        }
    }

    /**
     * Informs this controller that the mouse has been dragged.  The default
     * implementation uses this to move a corner of the rectangle used to
     * select the region. The other corner remains fixed at the point
     * where the mouse was at the moment its button was pressed..
     */
    public void mouseDragged(final MouseEvent event) {
        if (isDragging) {
            final Graphics2D graphics = getGraphics(event.getComponent());
            if (mouseSelectedArea == null) {
                graphics.drawLine(ox, oy, px, py);
                px = event.getX();
                py = event.getY();
                graphics.drawLine(ox, oy, px, py);
            } else {
                graphics.draw(mouseSelectedArea);
                int xmin = this.ox;
                int ymin = this.oy;
                int xmax = px = event.getX();
                int ymax = py = event.getY();
                if (xmin > xmax) {
                    final int xtmp = xmin;
                    xmin = xmax; xmax = xtmp;
                }
                if (ymin > ymax) {
                    final int ytmp = ymin;
                    ymin = ymax; ymax = ytmp;
                }
                mouseSelectedArea.setFrame(xmin, ymin, xmax - xmin, ymax - ymin);
                graphics.draw(mouseSelectedArea);
            }
            graphics.dispose();
            event.consume();
        }
    }

    /**
     * Informs this controller that the mouse button has been released.
     * The default implementation calls {@link #selectionPerformed} with
     * the bounds of the selected region as parameters.
     */
    public void mouseReleased(final MouseEvent event) {
        if (isDragging && (event.getModifiers() & MouseEvent.BUTTON1_MASK) != 0) {
            isDragging = false;
            final Component component = event.getComponent();
            component.removeMouseMotionListener(this);

            final Graphics2D graphics = getGraphics(event.getComponent());
            if (mouseSelectedArea == null) {
                graphics.drawLine(ox, oy, px, py);
            } else {
                graphics.draw(mouseSelectedArea);
            }
            graphics.dispose();
            px = event.getX();
            py = event.getY();
            selectionPerformed(ox, oy, px, py);
            event.consume();
        }
    }

    /**
     * Informs this controller that the mouse has been moved but not as a
     * result of the user selecting a region.  The default implementation
     * signals to the source component that {@code this} is no longer
     * interested in being informed about mouse movements.
     */
    public void mouseMoved(final MouseEvent event) {
        // Normally not necessary, but it seems that this "listener"
        // sometimes stays in place when it shouldn't.
        event.getComponent().removeMouseMotionListener(this);
    }
}
