/*
 * Copyright (c) 2008, Harald Kuhr
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 * * Redistributions of source code must retain the above copyright notice, this
 *   list of conditions and the following disclaimer.
 *
 * * Redistributions in binary form must reproduce the above copyright notice,
 *   this list of conditions and the following disclaimer in the documentation
 *   and/or other materials provided with the distribution.
 *
 * * Neither the name of the copyright holder nor the names of its
 *   contributors may be used to endorse or promote products derived from
 *   this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
 * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
 * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
 * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
 * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
 * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 */

package com.twelvemonkeys.imageio;

import com.twelvemonkeys.image.BufferedImageIcon;
import com.twelvemonkeys.image.ImageUtil;
import com.twelvemonkeys.imageio.util.IIOUtil;

import javax.imageio.*;
import javax.imageio.metadata.IIOMetadata;
import javax.imageio.spi.ImageReaderSpi;
import javax.imageio.stream.ImageInputStream;
import javax.swing.*;
import java.awt.*;
import java.awt.datatransfer.DataFlavor;
import java.awt.datatransfer.Transferable;
import java.awt.datatransfer.UnsupportedFlavorException;
import java.awt.event.*;
import java.awt.image.BufferedImage;
import java.awt.image.IndexColorModel;
import java.io.File;
import java.io.IOException;
import java.lang.reflect.InvocationTargetException;
import java.util.Arrays;
import java.util.Iterator;

/**
 * Abstract base class for image readers.
 *
 * @author <a href="mailto:harald.kuhr@gmail.com">Harald Kuhr</a>
 * @author last modified by $Author: haraldk$
 * @version $Id: ImageReaderBase.java,v 1.0 Sep 20, 2007 5:28:37 PM haraldk Exp$
 */
public abstract class ImageReaderBase extends ImageReader {

    private static final Point ORIGIN = new Point(0, 0);

    /**
     * For convenience. Only set if the input is an {@code ImageInputStream}.
     * @see #setInput(Object, boolean, boolean)
     */
    protected ImageInputStream imageInput;

    /**
     * Constructs an {@code ImageReader} and sets its
     * {@code originatingProvider} field to the supplied value.
     * <p>
     * Subclasses that make use of extensions should provide a
     * constructor with signature {@code (ImageReaderSpi,
     * Object)} in order to retrieve the extension object.  If
     * the extension object is unsuitable, an
     * {@code IllegalArgumentException} should be thrown.
     * </p>
     *
     * @param provider the {@code ImageReaderSpi} that is invoking this constructor, or {@code null}.
     */
    protected ImageReaderBase(final ImageReaderSpi provider) {
        super(provider);
    }

    /**
     * Overrides {@code setInput}, to allow easy access to the input, in case
     * it is an {@code ImageInputStream}.
     *
     * @param input the {@code ImageInputStream} or other
     * {@code Object} to use for future decoding.
     * @param seekForwardOnly if {@code true}, images and metadata
     * may only be read in ascending order from this input source.
     * @param ignoreMetadata if {@code true}, metadata
     * may be ignored during reads.
     *
     * @exception IllegalArgumentException if {@code input} is
     * not an instance of one of the classes returned by the
     * originating service provider's {@code getInputTypes}
     * method, or is not an {@code ImageInputStream}.
     *
     * @see ImageInputStream
     */
    @Override
    public void setInput(final Object input, final boolean seekForwardOnly, final boolean ignoreMetadata) {
        resetMembers();
        super.setInput(input, seekForwardOnly, ignoreMetadata);

        if (input instanceof ImageInputStream) {
            imageInput = (ImageInputStream) input;
        }
        else {
            imageInput = null;
        }
    }

    @Override
    public void dispose() {
        resetMembers();
        super.dispose();
    }

    @Override
    public void reset() {
        resetMembers();
        super.reset();
    }

    /**
     * Resets all member variables. This method is by default invoked from:
     * <ul>
     *  <li>{@link #setInput(Object, boolean, boolean)}</li>
     *  <li>{@link #dispose()}</li>
     *  <li>{@link #reset()}</li>
     * </ul>
     *
     */
    protected abstract void resetMembers();

    /**
     * Default implementation that always returns {@code null}.
     *
     * @param imageIndex ignored, unless overridden
     * @return {@code null}, unless overridden
     * @throws IOException never, unless overridden.
     */
    public IIOMetadata getImageMetadata(int imageIndex) throws IOException {
        return null;
    }

    /**
     * Default implementation that always returns {@code null}.
     *
     * @return {@code null}, unless overridden
     * @throws IOException never, unless overridden.
     */
    public IIOMetadata getStreamMetadata() throws IOException {
        return null;
    }

    /**
     * Default implementation that always returns {@code 1}.
     *
     * @param allowSearch ignored, unless overridden
     * @return {@code 1}, unless overridden
     * @throws IOException never, unless overridden
     */
    public int getNumImages(boolean allowSearch) throws IOException {
        assertInput();
        return 1;
    }

    /**
     * Convenience method to make sure image index is within bounds.
     *
     * @param index the image index
     *
     * @throws java.io.IOException if an error occurs during reading
     * @throws IndexOutOfBoundsException if not {@code minIndex <= index < numImages}
     */
    protected void checkBounds(int index) throws IOException {
        assertInput();
        if (index < getMinIndex()) {
            throw new IndexOutOfBoundsException("index < minIndex");
        }

        int numImages = getNumImages(false);
        if (numImages != -1 && index >= numImages) {
            throw new IndexOutOfBoundsException("index >= numImages (" + index + " >= " + numImages + ")");
        }
    }

    /**
     * Makes sure input is set.
     *
     * @throws IllegalStateException if {@code getInput() == null}.
     */
    protected void assertInput() {
        if (getInput() == null) {
            throw new IllegalStateException("getInput() == null");
        }
    }

    /**
     * Returns the {@code BufferedImage} to which decoded pixel data should be written.
     * <p>
     * As {@link javax.imageio.ImageReader#getDestination} but tests if the explicit destination
     * image (if set) is valid according to the {@code ImageTypeSpecifier}s given in {@code types}.
     * </p>
     *
     * @param param an {@code ImageReadParam} to be used to get
     * the destination image or image type, or {@code null}.
     * @param types an {@code Iterator} of
     * {@code ImageTypeSpecifier}s indicating the legal image
     * types, with the default first.
     * @param width the true width of the image or tile begin decoded.
     * @param height the true width of the image or tile being decoded.
     *
     * @return the {@code BufferedImage} to which decoded pixel
     * data should be written.
     *
     * @exception javax.imageio.IIOException if the {@code ImageTypeSpecifier} or {@code BufferedImage}
     * specified by {@code param} does not match any of the legal
     * ones from {@code types}.
     * @throws IllegalArgumentException if {@code types}
     * is {@code null} or empty, or if an object not of type
     * {@code ImageTypeSpecifier} is retrieved from it.
     * Or, if the resulting image would have a width or height less than 1,
     * or if the product of {@code width} and {@code height} of the resulting image is greater than
     * {@code Integer.MAX_VALUE}.
     */
    public static BufferedImage getDestination(final ImageReadParam param, final Iterator<ImageTypeSpecifier> types,
                                               final int width, final int height) throws IIOException {
        // Adapted from http://java.net/jira/secure/attachment/29712/TIFFImageReader.java.patch,
        // to allow reading parts/tiles of huge images.

        if (types == null || !types.hasNext()) {
            throw new IllegalArgumentException("imageTypes null or empty!");
        }

        ImageTypeSpecifier imageType = null;

        // If param is non-null, use it
        if (param != null) {
            // Try to get the explicit destination image
            BufferedImage dest = param.getDestination();

            if (dest != null) {
                boolean found = false;

                while (types.hasNext()) {
                    ImageTypeSpecifier specifier = types.next();
                    int bufferedImageType = specifier.getBufferedImageType();

                    if (bufferedImageType != 0 && bufferedImageType == dest.getType()) {
                        // Known types equal, perfect match
                        found = true;
                        break;
                    }
                    else {
                        // If types are different, or TYPE_CUSTOM, test if
                        // - transferType is ok
                        // - bands are ok
                        // TODO: Test if color model is ok?
                        if (specifier.getSampleModel().getTransferType() == dest.getSampleModel().getTransferType()
                                && Arrays.equals(specifier.getSampleModel().getSampleSize(), dest.getSampleModel().getSampleSize())
                                && specifier.getNumBands() <= dest.getSampleModel().getNumBands()) {
                            found = true;
                            break;
                        }
                    }
                }

                if (!found) {
                    throw new IIOException(String.format("Destination image from ImageReadParam does not match legal imageTypes from reader: %s", dest));
                }

                return dest;
            }

            // No image, get the image type
            imageType = param.getDestinationType();
        }

        // No info from param, use fallback image type
        if (imageType == null) {
            imageType = types.next();
        }
        else {
            boolean foundIt = false;

            while (types.hasNext()) {
                ImageTypeSpecifier type = types.next();

                if (type.equals(imageType)) {
                    foundIt = true;
                    break;
                }
            }

            if (!foundIt) {
                throw new IIOException(String.format("Destination type from ImageReadParam does not match legal imageTypes from reader: %s", imageType));
            }
        }

        Rectangle srcRegion = new Rectangle(0, 0, 0, 0);
        Rectangle destRegion = new Rectangle(0, 0, 0, 0);
        computeRegions(param, width, height, null, srcRegion, destRegion);

        int destWidth = destRegion.x + destRegion.width;
        int destHeight = destRegion.y + destRegion.height;

        long dimension = (long) destWidth * destHeight;
        if (dimension > Integer.MAX_VALUE) {
            throw new IIOException(String.format("destination width * height > Integer.MAX_VALUE: %d", dimension));
        }

        long size = dimension * imageType.getSampleModel().getNumDataElements();
        if (size > Integer.MAX_VALUE) {
            throw new IIOException(String.format("destination width * height * samplesPerPixel > Integer.MAX_VALUE: %d", size));
        }

        // Create a new image based on the type specifier
        return imageType.createBufferedImage(destWidth, destHeight);
    }

    /**
     * Utility method for getting the area of interest (AOI) of an image.
     * The AOI is defined by the {@link javax.imageio.IIOParam#setSourceRegion(java.awt.Rectangle)}
     * method.
     * <p>
     * Note: If it is possible for the reader to read the AOI directly, such a
     * method should be used instead, for efficiency.
     * </p>
     *
     * @param pImage the image to get AOI from
     * @param pParam the param optionally specifying the AOI
     *
     * @return a {@code BufferedImage} containing the area of interest (source
     * region), or the original image, if no source region was set, or
     * {@code pParam} was {@code null}
     */
    protected static BufferedImage fakeAOI(BufferedImage pImage, ImageReadParam pParam) {
        return IIOUtil.fakeAOI(pImage, getSourceRegion(pParam, pImage.getWidth(), pImage.getHeight()));
    }

    /**
     * Utility method for getting the subsampled image.
     * The subsampling is defined by the
     * {@link javax.imageio.IIOParam#setSourceSubsampling(int, int, int, int)}
     * method.
     * <p>
     * NOTE: This method does not take the subsampling offsets into
     * consideration.
     * </p>
     * <p>
     * Note: If it is possible for the reader to subsample directly, such a
     * method should be used instead, for efficiency.
     * </p>
     *
     * @param pImage the image to subsample
     * @param pParam the param optionally specifying subsampling
     *
     * @return an {@code Image} containing the subsampled image, or the
     * original image, if no subsampling was specified, or
     * {@code pParam} was {@code null}
     */
    protected static Image fakeSubsampling(Image pImage, ImageReadParam pParam) {
        return IIOUtil.fakeSubsampling(pImage, pParam);
    }

    /**
     * Tests if param has explicit destination.
     *
     * @param pParam the image read parameter, or {@code null}
     * @return true if {@code pParam} is non-{@code null} and either its {@code getDestination},
     * {@code getDestinationType} returns a non-{@code null} value,
     * or {@code getDestinationOffset} returns a {@link Point} that is not the upper left corner {@code (0, 0)}.
     */
    protected static boolean hasExplicitDestination(final ImageReadParam pParam) {
        return pParam != null &&
                (
                        pParam.getDestination() != null || pParam.getDestinationType() != null ||
                                !ORIGIN.equals(pParam.getDestinationOffset())
                );
    }

    public static void main(String[] pArgs) throws IOException {
        BufferedImage image = ImageIO.read(new File(pArgs[0]));
        if (image == null) {
            System.err.println("Supported formats: " + Arrays.toString(IIOUtil.getNormalizedReaderFormatNames()));
            System.exit(1);
        }
        showIt(image, pArgs[0]);
    }

    protected static void showIt(final BufferedImage pImage, final String pTitle) {
        try {
            SwingUtilities.invokeAndWait(new Runnable() {
                public void run() {
                    JFrame frame = new JFrame(pTitle);

                    frame.getRootPane().getActionMap().put("window-close", new AbstractAction() {
                        public void actionPerformed(ActionEvent e) {
                            Window window = SwingUtilities.getWindowAncestor((Component) e.getSource());
                            window.setVisible(false);
                            window.dispose();
                        }
                    });
                    frame.getRootPane().getInputMap().put(KeyStroke.getKeyStroke(KeyEvent.VK_W, Toolkit.getDefaultToolkit().getMenuShortcutKeyMask()), "window-close");
                    frame.addWindowListener(new ExitIfNoWindowPresentHandler());
                    frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);

                    frame.setLocationByPlatform(true);
                    JPanel pane = new JPanel(new BorderLayout());
                    JScrollPane scroll = new JScrollPane(pImage != null ? new ImageLabel(pImage) : new JLabel("(no image data)", JLabel.CENTER));
                    scroll.setBorder(null);
                    pane.add(scroll);
                    frame.setContentPane(pane);
                    frame.pack();
                    frame.setVisible(true);
                }
            });
        }
        catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        catch (InvocationTargetException e) {
            if (e.getCause() instanceof RuntimeException) {
                throw (RuntimeException) e.getCause();
            }

            throw new RuntimeException(e);
        }
    }

    private static class ImageLabel extends JLabel {
        static final String ZOOM_IN = "zoom-in";
        static final String ZOOM_OUT = "zoom-out";
        static final String ZOOM_ACTUAL = "zoom-actual";
        static final String ZOOM_FIT = "zoom-fit";

        private BufferedImage image;

        Paint backgroundPaint;

        final Paint checkeredBG;
        final Color defaultBG;

        public ImageLabel(final BufferedImage pImage) {
            super(new BufferedImageIcon(pImage));
            setOpaque(false);
            setCursor(Cursor.getPredefinedCursor(Cursor.CROSSHAIR_CURSOR));

            image = pImage;
            checkeredBG = createTexture();

            // For indexed color, default to the color of the transparent pixel, if any 
            defaultBG = getDefaultBackground(pImage);

            backgroundPaint = defaultBG != null ? defaultBG : checkeredBG;

            setupActions();
            setComponentPopupMenu(createPopupMenu());
            addMouseListener(new MouseAdapter() {
                @Override
                public void mouseClicked(MouseEvent e) {
                    if (e.isPopupTrigger()) {
                        getComponentPopupMenu().show(ImageLabel.this, e.getX(), e.getY());
                    }
                }
            });

            setTransferHandler(new TransferHandler() {
                @Override
                public int getSourceActions(JComponent c) {
                    return COPY;
                }

                @Override
                protected Transferable createTransferable(JComponent c) {
                    return new ImageTransferable(image);
                }

                @Override
                public boolean importData(JComponent comp, Transferable t) {
                    if (canImport(comp, t.getTransferDataFlavors())) {
                        try {
                            Image transferData = (Image) t.getTransferData(DataFlavor.imageFlavor);
                            image = ImageUtil.toBuffered(transferData);
                            setIcon(new BufferedImageIcon(image));

                            return true;
                        }
                        catch (UnsupportedFlavorException | IOException ignore) {
                        }
                    }

                    return false;
                }

                @Override
                public boolean canImport(JComponent comp, DataFlavor[] transferFlavors) {
                    for (DataFlavor flavor : transferFlavors) {
                        if (flavor.equals(DataFlavor.imageFlavor)) {
                            return true;
                        }
                    }

                    return false;
                }
            });
        }

        private void setupActions() {
            // Mac weirdness... VK_MINUS/VK_PLUS seems to map to english key map always...
            bindAction(new ZoomAction("Zoom in", 2), ZOOM_IN,
                    KeyStroke.getKeyStroke('+'),
                    KeyStroke.getKeyStroke(KeyEvent.VK_PLUS, Toolkit.getDefaultToolkit().getMenuShortcutKeyMask()),
                    KeyStroke.getKeyStroke(KeyEvent.VK_ADD, Toolkit.getDefaultToolkit().getMenuShortcutKeyMask()));
            bindAction(new ZoomAction("Zoom out", .5), ZOOM_OUT,
                    KeyStroke.getKeyStroke('-'),
                    KeyStroke.getKeyStroke(KeyEvent.VK_MINUS, Toolkit.getDefaultToolkit().getMenuShortcutKeyMask()),
                    KeyStroke.getKeyStroke(KeyEvent.VK_SUBTRACT, Toolkit.getDefaultToolkit().getMenuShortcutKeyMask()));
            bindAction(new ZoomAction("Zoom actual"), ZOOM_ACTUAL,
                    KeyStroke.getKeyStroke('0'),
                    KeyStroke.getKeyStroke(KeyEvent.VK_0, Toolkit.getDefaultToolkit().getMenuShortcutKeyMask()));
            bindAction(new ZoomToFitAction("Zoom fit"), ZOOM_FIT,
                    KeyStroke.getKeyStroke('9'),
                    KeyStroke.getKeyStroke(KeyEvent.VK_9, Toolkit.getDefaultToolkit().getMenuShortcutKeyMask()));

            bindAction(TransferHandler.getCopyAction(), (String) TransferHandler.getCopyAction().getValue(Action.NAME), KeyStroke.getKeyStroke(KeyEvent.VK_C, Toolkit.getDefaultToolkit().getMenuShortcutKeyMask()));
            bindAction(TransferHandler.getPasteAction(), (String) TransferHandler.getPasteAction().getValue(Action.NAME), KeyStroke.getKeyStroke(KeyEvent.VK_V, Toolkit.getDefaultToolkit().getMenuShortcutKeyMask()));
        }

        private void bindAction(final Action action, final String key, final KeyStroke... keyStrokes) {
            for (KeyStroke keyStroke : keyStrokes) {
                getInputMap(WHEN_IN_FOCUSED_WINDOW).put(keyStroke, key);
            }

            getActionMap().put(key, action);
        }

        private JPopupMenu createPopupMenu() {
            JPopupMenu popup = new JPopupMenu();

            popup.add(getActionMap().get(ZOOM_FIT));
            popup.add(getActionMap().get(ZOOM_ACTUAL));
            popup.add(getActionMap().get(ZOOM_IN));
            popup.add(getActionMap().get(ZOOM_OUT));
            popup.addSeparator();

            ButtonGroup group = new ButtonGroup();

            JMenu background = new JMenu("Background");
            popup.add(background);

            ChangeBackgroundAction checkered = new ChangeBackgroundAction("Checkered", checkeredBG);
            checkered.putValue(Action.SELECTED_KEY, backgroundPaint == checkeredBG);
            addCheckBoxItem(checkered, background, group);
            background.addSeparator();
            addCheckBoxItem(new ChangeBackgroundAction("White", Color.WHITE), background, group);
            addCheckBoxItem(new ChangeBackgroundAction("Light", Color.LIGHT_GRAY), background, group);
            addCheckBoxItem(new ChangeBackgroundAction("Gray", Color.GRAY), background, group);
            addCheckBoxItem(new ChangeBackgroundAction("Dark", Color.DARK_GRAY), background, group);
            addCheckBoxItem(new ChangeBackgroundAction("Black", Color.BLACK), background, group);
            background.addSeparator();
            ChooseBackgroundAction chooseBackgroundAction = new ChooseBackgroundAction("Choose...", defaultBG != null ? defaultBG : new Color(0xFF6600));
            chooseBackgroundAction.putValue(Action.SELECTED_KEY, backgroundPaint == defaultBG);
            addCheckBoxItem(chooseBackgroundAction, background, group);

            return popup;
        }

        private void addCheckBoxItem(final Action pAction, final JMenu pPopup, final ButtonGroup pGroup) {
            JCheckBoxMenuItem item = new JCheckBoxMenuItem(pAction);
            pGroup.add(item);
            pPopup.add(item);
        }

        private static Color getDefaultBackground(BufferedImage pImage) {
            if (pImage.getColorModel() instanceof IndexColorModel) {
                IndexColorModel cm = (IndexColorModel) pImage.getColorModel();
                int transparent = cm.getTransparentPixel();
                if (transparent >= 0) {
                    return new Color(cm.getRGB(transparent), false);
                }
            }

            return null;
        }

        private static Paint createTexture() {
            GraphicsConfiguration graphicsConfiguration = GraphicsEnvironment.getLocalGraphicsEnvironment().getDefaultScreenDevice().getDefaultConfiguration();
            BufferedImage pattern = graphicsConfiguration.createCompatibleImage(20, 20);
            Graphics2D g = pattern.createGraphics();
            try {
                g.setColor(Color.LIGHT_GRAY);
                g.fillRect(0, 0, pattern.getWidth(), pattern.getHeight());
                g.setColor(Color.GRAY);
                g.fillRect(0, 0, pattern.getWidth() / 2, pattern.getHeight() / 2);
                g.fillRect(pattern.getWidth() / 2, pattern.getHeight() / 2, pattern.getWidth() / 2, pattern.getHeight() / 2);
            }
            finally {
                g.dispose();
            }

            return new TexturePaint(pattern, new Rectangle(pattern.getWidth(), pattern.getHeight()));
        }

        @Override
        protected void paintComponent(Graphics g) {
            Graphics2D gr = (Graphics2D) g;
            gr.setPaint(backgroundPaint);
            gr.fillRect(0, 0, getWidth(), getHeight());
            super.paintComponent(g);
        }

        private class ChangeBackgroundAction extends AbstractAction {
            protected Paint paint;

            public ChangeBackgroundAction(final String pName, final Paint pPaint) {
                super(pName);
                paint = pPaint;
            }

            public void actionPerformed(ActionEvent e) {
                backgroundPaint = paint;
                repaint();
            }
        }

        private class ChooseBackgroundAction extends ChangeBackgroundAction {
            public ChooseBackgroundAction(final String pName, final Color pColor) {
                super(pName, pColor);
                putValue(Action.SMALL_ICON, new Icon() {
                    public void paintIcon(Component c, Graphics pGraphics, int x, int y) {
                        Graphics g = pGraphics.create();
                        g.setColor((Color) paint);
                        g.fillRect(x, y, 16, 16);
                        g.dispose();
                    }

                    public int getIconWidth() {
                        return 16;
                    }

                    public int getIconHeight() {
                        return 16;
                    }
                });
            }

            @Override
            public void actionPerformed(ActionEvent e) {
                Color selected = JColorChooser.showDialog(ImageLabel.this, "Choose background", (Color) paint);
                if (selected != null) {
                    paint = selected;
                    super.actionPerformed(e);
                }
            }
        }

        private class ZoomAction extends AbstractAction {
            private final double zoomFactor;

            public ZoomAction(final String name, final double zoomFactor) {
                super(name);
                this.zoomFactor = zoomFactor;
            }

            public ZoomAction(final String name) {
                this(name, 0);
            }

            public void actionPerformed(final ActionEvent e) {
                if (zoomFactor <= 0) {
                    setIcon(new BufferedImageIcon(image));
                }
                else {
                    Icon current = getIcon();
                    int w = Math.max(Math.min((int) (current.getIconWidth() * zoomFactor), image.getWidth() * 16), image.getWidth() / 16);
                    int h = Math.max(Math.min((int) (current.getIconHeight() * zoomFactor), image.getHeight() * 16), image.getHeight() / 16);

                    setIcon(new BufferedImageIcon(image, Math.max(w, 2), Math.max(h, 2), w > image.getWidth() || h > image.getHeight()));
                }
            }
        }

        private class ZoomToFitAction extends ZoomAction {
            public ZoomToFitAction(final String name) {
                super(name, -1);
            }

            public void actionPerformed(final ActionEvent e) {
                JComponent source = (JComponent) e.getSource();

                if (source instanceof JMenuItem) {
                    JPopupMenu menu = (JPopupMenu) SwingUtilities.getAncestorOfClass(JPopupMenu.class, source);
                    source = (JComponent) menu.getInvoker();
                }

                Container container = SwingUtilities.getAncestorOfClass(JViewport.class, source);

                double ratioX = container.getWidth() / (double) image.getWidth();
                double ratioY = container.getHeight() / (double) image.getHeight();

                double zoomFactor = Math.min(ratioX, ratioY);

                int w = Math.max(Math.min((int) (image.getWidth() * zoomFactor), image.getWidth() * 16), image.getWidth() / 16);
                int h = Math.max(Math.min((int) (image.getHeight() * zoomFactor), image.getHeight() * 16), image.getHeight() / 16);

                setIcon(new BufferedImageIcon(image, w, h, zoomFactor > 1));
            }
        }

        private static class ImageTransferable implements Transferable {
            private final BufferedImage image;

            public ImageTransferable(final BufferedImage image) {
                this.image = image;
            }

            @Override
            public DataFlavor[] getTransferDataFlavors() {
                return new DataFlavor[] {DataFlavor.imageFlavor};
            }

            @Override
            public boolean isDataFlavorSupported(final DataFlavor flavor) {
                return DataFlavor.imageFlavor.equals(flavor);
            }

            @Override
            public Object getTransferData(final DataFlavor flavor) throws UnsupportedFlavorException {
                if (isDataFlavorSupported(flavor)) {
                    return image;
                }

                throw new UnsupportedFlavorException(flavor);
            }
        }
    }

    private static class ExitIfNoWindowPresentHandler extends WindowAdapter {
        @Override
        public void windowClosed(final WindowEvent e) {
            Window[] windows = Window.getWindows();

            if (windows == null || windows.length == 0) {
                System.exit(0);
            }
        }
    }
}
