/* * #%L * gitools-ui-platform * %% * Copyright (C) 2013 Universitat Pompeu Fabra - Biomedical Genomics group * %% * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program 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 General Public License for more details. * * You should have received a copy of the GNU General Public * License along with this program. If not, see * <http://www.gnu.org/licenses/gpl-3.0.html>. * #L% */ package org.gitools.ui.platform.imageviewer; import javax.swing.*; import javax.swing.event.ChangeEvent; import javax.swing.event.ChangeListener; import javax.swing.event.MouseInputListener; import java.applet.Applet; import java.awt.*; import java.awt.event.ComponentAdapter; import java.awt.event.ComponentEvent; import java.awt.event.MouseEvent; import java.awt.geom.AffineTransform; import java.awt.geom.NoninvertibleTransformException; import java.awt.geom.Point2D; import java.awt.image.BufferedImage; import java.beans.PropertyChangeEvent; import java.beans.PropertyChangeListener; import java.beans.PropertyChangeSupport; import java.util.ArrayList; import java.util.List; /** * The component that displays the image itself. * * @author Kazó Csaba */ class ImageComponent extends JComponent { private ResizeStrategy resizeStrategy = ResizeStrategy.SHRINK_TO_FIT; private BufferedImage image; private boolean pixelatedZoom = false; private Object interpolationType = RenderingHints.VALUE_INTERPOLATION_BICUBIC; private double zoomFactor = 1; private final List<ImageMouseMotionListener> moveListeners = new ArrayList<>(4); private final List<ImageMouseClickListener> clickListeners = new ArrayList<>(4); private final MouseEventTranslator mouseEventTranslator = new MouseEventTranslator(); private final PaintManager paintManager = new PaintManager(); /* Handles repositioning the scroll pane when the image is resized so that the same area remains visible. */ class Rescroller { private Point2D preparedCenter = null; void prepare() { if (image != null && hasSize()) { Rectangle viewRect = viewer.getScrollPane().getViewport().getViewRect(); preparedCenter = new Point2D.Double(viewRect.getCenterX(), viewRect.getCenterY()); try { getImageTransform().inverseTransform(preparedCenter, preparedCenter); } catch (NoninvertibleTransformException e) { throw new Error(e); } } } void rescroll() { if (preparedCenter != null) { Dimension viewSize = viewer.getScrollPane().getViewport().getExtentSize(); getImageTransform().transform(preparedCenter, preparedCenter); Rectangle view = new Rectangle((int) Math.round(preparedCenter.getX() - viewSize.width / 2.0), (int) Math.round(preparedCenter.getY() - viewSize.height / 2.0), viewSize.width, viewSize.height); preparedCenter = null; scrollRectToVisible(view); } } } private Rescroller rescroller = new Rescroller(); private final PropertyChangeSupport propertyChangeSupport; private final ImageViewer viewer; public ImageComponent(ImageViewer viewer, PropertyChangeSupport propertyChangeSupport) { this.viewer = viewer; this.propertyChangeSupport = propertyChangeSupport; mouseEventTranslator.register(this); setOpaque(true); viewer.getScrollPane().getViewport().addChangeListener(new ChangeListener() { @Override public void stateChanged(ChangeEvent e) { /* * Here the viewer might not have a size yet, because we might be before the first layout. * But that's alright. As soon as we get our size, the viewport will send another state change. */ if (hasSize()) mouseEventTranslator.correctionalFire(); } }); } private boolean hasSize() { return getWidth() > 0 && getHeight() > 0; } @Override public Dimension getMaximumSize() { return new Dimension(Integer.MAX_VALUE, Integer.MAX_VALUE); } public void addImageMouseMoveListener(ImageMouseMotionListener l) { if (l != null) moveListeners.add(l); } public void removeImageMouseMoveListener(ImageMouseMotionListener l) { if (l != null) moveListeners.remove(l); } public void addImageMouseClickListener(ImageMouseClickListener l) { if (l != null) clickListeners.add(l); } public void removeImageMouseClickListener(ImageMouseClickListener l) { if (l != null) clickListeners.remove(l); } public void setImage(BufferedImage newImage) { BufferedImage oldImage = image; image = newImage; paintManager.notifyChanged(); if (oldImage != newImage && (oldImage == null || newImage == null || oldImage.getWidth() != newImage.getWidth() || oldImage.getHeight() != newImage.getHeight())) revalidate(); repaint(); propertyChangeSupport.firePropertyChange("image", oldImage, newImage); } public BufferedImage getImage() { return image; } /** * Preforms all necessary actions to ensure that the viewer is resized to its proper size. It does that by invoking * {@code validate()} on the viewer's validateRoot. It also issues a {@code repaint()}. */ private void resizeNow() { invalidate(); // find the validate root; adapted from the package-private SwingUtilities.getValidateRoot Container root = null; Container c = this; for (; c != null; c = c.getParent()) { if (!c.isDisplayable() || c instanceof CellRendererPane) { return; } if (c.isValidateRoot()) { root = c; break; } } if (root == null) return; for (; c != null; c = c.getParent()) { if (!c.isDisplayable() || !c.isVisible()) { return; } if (c instanceof Window || c instanceof Applet) { break; } } if (c == null) return; root.validate(); repaint(); } public void setResizeStrategy(ResizeStrategy resizeStrategy) { if (resizeStrategy == this.resizeStrategy) return; rescroller.prepare(); ResizeStrategy oldResizeStrategy = this.resizeStrategy; this.resizeStrategy = resizeStrategy; boolean canRescroll = viewer.getSynchronizer().resizeStrategyChangedCanIRescroll(viewer); resizeNow(); if (canRescroll) { rescroller.rescroll(); viewer.getSynchronizer().doneRescrolling(viewer); } propertyChangeSupport.firePropertyChange("resizeStrategy", oldResizeStrategy, resizeStrategy); } public ResizeStrategy getResizeStrategy() { return resizeStrategy; } public void setInterpolationType(Object type) { if (interpolationType == type) return; if ( type != RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR && type != RenderingHints.VALUE_INTERPOLATION_BILINEAR && type != RenderingHints.VALUE_INTERPOLATION_BICUBIC) throw new IllegalArgumentException("Invalid interpolation type; use one of the RenderingHints constants"); Object old = this.interpolationType; this.interpolationType = type; viewer.getSynchronizer().interpolationTypeChanged(viewer); paintManager.notifyChanged(); repaint(); propertyChangeSupport.firePropertyChange("interpolationType", old, type); } public Object getInterpolationType() { return interpolationType; } public void setPixelatedZoom(boolean pixelatedZoom) { if (pixelatedZoom == this.pixelatedZoom) return; this.pixelatedZoom = pixelatedZoom; viewer.getSynchronizer().pixelatedZoomChanged(viewer); paintManager.notifyChanged(); repaint(); propertyChangeSupport.firePropertyChange("pixelatedZoom", !pixelatedZoom, pixelatedZoom); } public boolean isPixelatedZoom() { return pixelatedZoom; } /** * Returns the zoom factor used when resize strategy is CUSTOM_ZOOM. */ public double getZoomFactor() { return zoomFactor; } /** * Sets the zoom factor to use when the resize strategy is CUSTOM_ZOOM. * <p/> * Note that calling this function does not change the current resize strategy. * * @throws IllegalArgumentException if {@code newZoomFactor} is not a positive number */ public void setZoomFactor(double newZoomFactor) { if (zoomFactor == newZoomFactor) return; if (newZoomFactor <= 0 || Double.isInfinite(newZoomFactor) || Double.isNaN(newZoomFactor)) throw new IllegalArgumentException("Invalid zoom factor: " + newZoomFactor); if (getResizeStrategy() == ResizeStrategy.CUSTOM_ZOOM) { rescroller.prepare(); } double oldZoomFactor = zoomFactor; zoomFactor = newZoomFactor; boolean canRescroll = viewer.getSynchronizer().zoomFactorChangedCanIRescroll(viewer); if (getResizeStrategy() == ResizeStrategy.CUSTOM_ZOOM) { resizeNow(); // do not rescroll if we're following another viewer; the scrolling will be synchronized later if (canRescroll) { rescroller.rescroll(); viewer.getSynchronizer().doneRescrolling(viewer); } } else { // no rescrolling is necessary, actually if (canRescroll) viewer.getSynchronizer().doneRescrolling(viewer); } propertyChangeSupport.firePropertyChange("zoomFactor", oldZoomFactor, newZoomFactor); } @Override public Dimension getPreferredSize() { if (image == null) { return new Dimension(); } else if (resizeStrategy == ResizeStrategy.CUSTOM_ZOOM) { return new Dimension((int) Math.ceil(image.getWidth() * zoomFactor), (int) Math.ceil(image.getHeight() * zoomFactor)); } else return new Dimension(image.getWidth(), image.getHeight()); } /** * Returns the image pixel that is under the given point. * * @param p a point in component coordinate system * @return the corresponding image pixel, or <code>null</code> if the point is outside the image */ public Point pointToPixel(Point p) { return pointToPixel(p, true); } /** * Returns the image pixel corresponding to the given point. If the <code>clipToImage</code> * parameter is <code>false</code>, then the function will return an appropriately positioned * pixel on an infinite plane, even if the point is outside the image bounds. If * <code>clipToImage</code> is <code>true</code> then the function will return <code>null</code> * for such positions, and any non-null return value will be a valid image pixel. * * @param p a point in component coordinate system * @param clipToImage whether the function should return <code>null</code> for positions outside * the image bounds * @return the corresponding image pixel * @throws IllegalStateException if there is no image set or if the size of the viewer is 0 (for example because * it is not in a visible component) */ public Point pointToPixel(Point p, boolean clipToImage) { Point2D.Double fp = new Point2D.Double(p.x + .5, p.y + .5); try { getImageTransform().inverseTransform(fp, fp); } catch (NoninvertibleTransformException ex) { throw new Error("Image transformation not invertible"); } p.x = (int) Math.floor(fp.x); p.y = (int) Math.floor(fp.y); if (clipToImage && (p.x < 0 || p.y < 0 || p.x >= image.getWidth() || p.y >= image.getHeight())) { return null; } return p; } @Override protected void paintComponent(Graphics g) { paintManager.paintComponent(g); } /** * Returns the transformation that is applied to the image. Most commonly the transformation * is the concatenation of a uniform scale and a translation. * <p/> * The <code>AffineTransform</code> * instance returned by this method should not be modified. * * @return the transformation applied to the image before painting * @throws IllegalStateException if there is no image set or if the size of the viewer is 0 (for example because * it is not in a visible component) */ public AffineTransform getImageTransform() { if (getImage() == null) throw new IllegalStateException("No image"); if (!hasSize()) throw new IllegalStateException("Viewer size is zero"); double currentZoom = getCurrentZoom(); AffineTransform tr = new AffineTransform(); tr.setToTranslation((getWidth() - image.getWidth() * currentZoom) / 2.0, (getHeight() - image.getHeight() * currentZoom) / 2.0); tr.scale(currentZoom, currentZoom); return tr; } public double getCurrentZoom() { double currentZoom; switch (resizeStrategy) { case NO_RESIZE: currentZoom = 1; break; case SHRINK_TO_FIT: currentZoom = Math.min(getSizeRatio(), 1); break; case RESIZE_TO_FIT: currentZoom = getSizeRatio(); break; case CUSTOM_ZOOM: currentZoom = zoomFactor; break; default: throw new Error("Unhandled resize strategy"); } return currentZoom; } private double getSizeRatio() { return Math.min(getWidth() / (double) image.getWidth(), getHeight() / (double) image.getHeight()); } /** * Helper class that generates ImageMouseEvents by translating normal mouse events onto * the image. */ private class MouseEventTranslator implements MouseInputListener, PropertyChangeListener { /** * This flag is true if the mouse cursor is inside the bounds of the image. */ private boolean on = false; /** * The last position reported. This is used to avoid multiple successive image mouse motion events * with the same position. */ private Point lastPosition = null; /** * Sets up this translator. */ private void register(ImageComponent ic) { ic.addMouseListener(this); ic.addMouseMotionListener(this); ic.propertyChangeSupport.addPropertyChangeListener(this); ic.addComponentListener(new ComponentAdapter() { @Override public void componentResized(ComponentEvent e) { correctionalFire(); } }); } private void handleMouseAt(Point position, MouseEvent event) { if (image == null) { if (on) { on = false; fireMouseExit(); } } else { if (position != null) position = pointToPixel(position); if (position == null) { if (on) { on = false; fireMouseExit(); } } else { if (!on) { on = true; lastPosition = null; fireMouseEnter(position.x, position.y, event); } if (!position.equals(lastPosition)) { lastPosition = position; fireMouseAtPixel(position.x, position.y, event); } } } } @Override public void mouseClicked(MouseEvent e) { if (image == null || !on) return; Point p = pointToPixel(e.getPoint()); if (p != null) { fireMouseClickedAtPixel(p.x, p.y, e); } } @Override public void mouseEntered(MouseEvent e) { if (image != null) { Point p = pointToPixel(e.getPoint()); if (p != null) { on = true; fireMouseEnter(p.x, p.y, e); fireMouseAtPixel(p.x, p.y, e); } } } @Override public void mouseExited(MouseEvent e) { if (on) { on = false; fireMouseExit(); } } @Override public void mouseMoved(MouseEvent e) { handleMouseAt(e.getPoint(), e); } @Override public void mouseDragged(MouseEvent e) { if (image == null) return; Point p = pointToPixel(e.getPoint(), false); fireMouseDrag(p.x, p.y, e); } @Override public void propertyChange(PropertyChangeEvent evt) { if ( "image".equals(evt.getPropertyName()) || "resizeStrategy".equals(evt.getPropertyName()) || (getResizeStrategy() == ResizeStrategy.CUSTOM_ZOOM && "zoomFactor".equals(evt.getPropertyName()))) { correctionalFire(); } } /** * Fires a motion event based on the current cursor position. Use this method if something other than mouse motion * changed where the cursor is relative to the image. */ private void correctionalFire() { /** * We use our parent, LayeredImageView, to locate the mouse. If the viewer has an overlay, then * ImageComponent.getMousePosition will return null because the mouse is over the overlay and not the image * component. */ handleMouseAt(getParent().getMousePosition(true), null); } private void fireMouseAtPixel(int x, int y, MouseEvent ev) { ImageMouseEvent e = null; for (ImageMouseMotionListener imageMouseMoveListener : moveListeners) { if (e == null) e = new ImageMouseEvent(viewer, image, x, y, ev); imageMouseMoveListener.mouseMoved(e); } } private void fireMouseClickedAtPixel(int x, int y, MouseEvent ev) { ImageMouseEvent e = null; for (ImageMouseClickListener imageMouseClickListener : clickListeners) { if (e == null) e = new ImageMouseEvent(viewer, image, x, y, ev); imageMouseClickListener.mouseClicked(e); } } private void fireMouseEnter(int x, int y, MouseEvent ev) { ImageMouseEvent e = null; for (ImageMouseMotionListener imageMouseMoveListener : moveListeners) { if (e == null) e = new ImageMouseEvent(viewer, image, x, y, ev); imageMouseMoveListener.mouseEntered(e); } } private void fireMouseExit() { ImageMouseEvent e = null; for (ImageMouseMotionListener imageMouseMoveListener : moveListeners) { if (e == null) e = new ImageMouseEvent(viewer, image, -1, -1, null); imageMouseMoveListener.mouseExited(e); } } private void fireMouseDrag(int x, int y, MouseEvent ev) { ImageMouseEvent e = null; for (ImageMouseMotionListener imageMouseMoveListener : moveListeners) { if (e == null) e = new ImageMouseEvent(viewer, image, x, y, ev); imageMouseMoveListener.mouseDragged(e); } } @Override public void mousePressed(MouseEvent e) { } @Override public void mouseReleased(MouseEvent e) { } } /** * Helper class that manages the actual painting. */ private class PaintManager { BufferedImage cachedImage = null; boolean cachedImageChanged = false; AffineTransform cachedTransform; private void doPaint(Graphics2D gg, AffineTransform imageTransform) { gg.setColor(getBackground()); gg.fillRect(0, 0, getWidth(), getHeight()); gg.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); if (pixelatedZoom && imageTransform.getScaleX() >= 1) gg.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR); else gg.setRenderingHint(RenderingHints.KEY_INTERPOLATION, interpolationType); gg.drawImage(image, imageTransform, ImageComponent.this); } private void ensureCachedValid(AffineTransform imageTransform) { boolean cacheValid; // create the image if necessary; if the existing one is sufficiently large, use it if (cachedImage == null || cachedImage.getWidth() < getWidth() || cachedImage.getHeight() < getHeight()) { cachedImage = getGraphicsConfiguration().createCompatibleImage(getWidth(), getHeight()); cacheValid = false; } else { cacheValid = cachedTransform.equals(imageTransform) && !cachedImageChanged; } if (!cacheValid) { Graphics2D gg = cachedImage.createGraphics(); doPaint(gg, imageTransform); gg.dispose(); cachedImageChanged = false; cachedTransform = new AffineTransform(imageTransform); } } /** * Called when a property which affects how the component is painted changes. This invalidates the cache and causes * it to be redrawn upon the next paint request. */ public void notifyChanged() { cachedImageChanged = true; } public void paintComponent(Graphics g) { if (image == null) { Graphics2D gg = (Graphics2D) g.create(); gg.setColor(getBackground()); gg.fillRect(0, 0, getWidth(), getHeight()); gg.dispose(); return; } AffineTransform imageTransform = getImageTransform(); if (imageTransform.getScaleX() < 1 && interpolationType != RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR) { /* * We're shrinking the image; instead of letting the Graphics object do it every time, we do it and cache * the result. */ ensureCachedValid(imageTransform); g.drawImage(cachedImage, 0, 0, ImageComponent.this); } else { // draw the image directly Graphics2D gg = (Graphics2D) g.create(); doPaint(gg, imageTransform); gg.dispose(); } } } }