hu.kazocsaba.imageviewer.ImageComponent Maven / Gradle / Ivy
package hu.kazocsaba.imageviewer;
import java.applet.Applet;
import java.awt.Container;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.RenderingHints;
import java.awt.Window;
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;
import javax.swing.CellRendererPane;
import javax.swing.JComponent;
import javax.swing.event.ChangeEvent;
import javax.swing.event.ChangeListener;
import javax.swing.event.MouseInputListener;
/**
* 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 moveListeners = new ArrayList(4);
private final List 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.
*
* 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 null
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 clipToImage
* parameter is false
, then the function will return an appropriately positioned
* pixel on an infinite plane, even if the point is outside the image bounds. If
* clipToImage
is true
then the function will return null
* 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 null
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.
*
* The AffineTransform
* 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;
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");
}
AffineTransform tr=new AffineTransform();
tr.setToTranslation((getWidth()-image.getWidth()*currentZoom)/2.0, (getHeight()-image.getHeight()*currentZoom)/2.0);
tr.scale(currentZoom, currentZoom);
return tr;
}
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()