package ie.dcu.swt; import ie.dcu.swt.event.*; import java.util.*; import java.util.List; import org.eclipse.swt.SWT; import org.eclipse.swt.custom.ScrolledComposite; import org.eclipse.swt.events.*; import org.eclipse.swt.graphics.*; import org.eclipse.swt.layout.FillLayout; import org.eclipse.swt.widgets.*; public class ImageControl extends Composite { /** * Minimum allowed zoom level (5% of original). */ public static final float MIN_ZOOM = 0.05f; /** * Maximum allowed zoom level (1000% of original) */ public static final float MAX_ZOOM = 10.f; /** * Default zoom in/out step (10%) */ public static final float DEFAULT_ZOOM_STEP = 0.1f; /** * Default zoom level (100%) */ public static final float DEFAULT_ZOOM = 1.0f; /** * Canvas where the image is drawn. */ private final Canvas canvas; /** * Scroll bars. */ private final ScrolledComposite scroller; /** * The images bounding rectangle (after scaling + translation). */ private final Rectangle bounds; /** * Listeners for zoom events. */ private final List listeners; /** * The original image. */ private ObservableImage image; /** * The scaled image instance. */ private Image scaled; /** * Current zoom level. */ private float scale; /** * Current zoom in/out step. */ private float scaleStep; /** * Flag to indicate that a black line border around the image * should be painted. Default is true. */ private boolean drawBorder; /** * Cached original image bounding rectangle. */ private transient Rectangle cachedImageBounds; /** * Construct an image view. The style can be any style that a Composite can * have. The parent may not be {@code null}. Although this class extends * Composite, it does not make sense to set a layout on it or to add other * widgets to it. * * @param parent * The parent Composite. * @param style * Style constants. */ public ImageControl(Composite parent, int style) { super(parent, style); // Fill entire composite setLayout(new FillLayout()); // Create scroll bars (always shown) scroller = new ScrolledComposite(this, SWT.H_SCROLL | SWT.V_SCROLL); // Create canvas (set no background for efficiency, we'll draw it ourselves) canvas = new Canvas(scroller, SWT.NO_BACKGROUND); // Initial bounds is an empty rectangle bounds = new Rectangle(0, 0, 0, 0); // Zoom listeners listeners = new ArrayList(2); // Setup scroll bars scroller.setExpandHorizontal(true); scroller.setExpandVertical(true); scroller.setContent(canvas); // Listen for paint events and repaint canvas canvas.addPaintListener(new PaintListener() { public void paintControl(PaintEvent e) { paintCanvas(e.gc); } }); // Listen for resizes and handle appropriately scroller.addControlListener(new ControlAdapter() { public void controlResized(ControlEvent e) { handleResize(); } }); // Listen for dispose event this.addDisposeListener(new DisposeListener() { public void widgetDisposed(DisposeEvent e) { handleDispose(); } }); // Set defaults scale = DEFAULT_ZOOM; scaleStep = DEFAULT_ZOOM_STEP; drawBorder = true; } /** * Called when the scroll area is resized */ protected void handleResize() { repaint(); } /** * Handle a dispose event. */ protected void handleDispose() { // Throw away the scaled image disposeScaledImage(); } /** * Called when the canvas needs to be painted. * * @param gc * The paint graphics context. */ protected void paintCanvas(GC gc) { // Determine the clip (visible canvas area) Rectangle clip = getVisibleCanvasArea(); if (!hasImage()) { // Fill background gc.fillRectangle(clip); return; } if (!hasScaledImage()) { // Create a scaled version of the image createScaledImage(); } // Set the clip gc.setClipping(clip); // Draw the scaled image gc.drawImage(scaled, bounds.x, bounds.y); // Draw background int tGap = bounds.y - clip.y; int bGap = bounds.y + bounds.height - clip.y + clip.height; int lGap = bounds.x - clip.x; int rGap = bounds.x + bounds.width - clip.x + clip.width; if (bounds.height < clip.height) { gc.fillRectangle(clip.x, clip.y, clip.width, tGap); gc.fillRectangle(clip.x, bounds.y + bounds.height, clip.width, bGap); } if (bounds.width < clip.width) { gc.fillRectangle(clip.x, clip.y + tGap, lGap, bounds.height); gc.fillRectangle(bounds.x + bounds.width, clip.y + tGap, rGap, bounds.height); } // Draw a border around the image if (drawBorder) { SwtUtils.setForeground(gc, SWT.COLOR_BLACK); gc.drawRectangle(bounds.x-1, bounds.y-1, bounds.width+1, bounds.height+1); } } /** * Handles changes in the image data. */ protected void handleImageChanged(ImageEvent e) { // If dimensions changed, re-constructed entire scaled image if (e.dimensionsChanged()) { cachedImageBounds = null; createScaledImage(); return; } // If no scaled image available, construct one if (!hasScaledImage()) { recomputeBounds(); // Construct scaled image scaled = new Image(getDisplay(), bounds.width, bounds.height); } Rectangle modified = e.modified.intersection(getImageBounds()); // Scale modified rectangle Rectangle target = SwtUtils.scale(modified, scale); // Create graphics context GC gc = new GC(scaled); // Set clip gc.setClipping(target); // Redraw modified part gc.drawImage( image.getImage(), modified.x, modified.y, modified.width, modified.height, target.x, target.y, target.width, target.height ); // Dispose graphics context gc.dispose(); // Repaint canvas repaint(modified); } /** * Returns the properly scaled version of the current image, after zooming has * been performed. Returns null if there is no current image. * * @return The scaled image, or null. */ public Image getScaledImage() { if (!hasScaledImage()) { createScaledImage(); } return scaled; } /** * Get the current image. Returns {@code null} if there is none. * * @return The image, or null. */ public ObservableImage getImage() { return image; } /** * Set the currently displayed image. This method has no effect if the given * image is the same as the currently displayed one. Otherwise, the image is * set to the new one, the zoom is reset to 1.0 and the control is repainted. * The given image may also be {@code null} to clear the display to its * background color. * *

Note: The old image is not disposed of. * * @param image * The image, or null. */ public void setImage(ObservableImage image) { if (this.image != image) { // Dispose of scaled image disposeScaledImage(); // Remove old listener if (this.image != null) { this.image.removeImageListener(imageListener); } // Set image this.image = image; // Add new listener if (this.image != null) { this.image.addImageListener(imageListener); } // Invalidate cached bounds this.cachedImageBounds = null; // Reset scale this.scale = 1.0f; // Repaint repaint(); } } /** * Set flag to indicate that a black line border around the image will be * drawn. Will repaint if changed. * * @param drawBorder * true to draw a border. */ public void setDrawBorder(boolean drawBorder) { if (this.drawBorder != drawBorder) { this.drawBorder = drawBorder; repaint(); } } /** * Returns true if the drawBorder flag is set. * * @return true if a black border around the image will be * drawn. * @see ImageControl#setDrawBorder(boolean) */ public boolean getDrawBorder() { return drawBorder; } /** * Returns {@code true} if the view has an image and it has not been disposed. * * @return true if view has an image */ public boolean hasImage() { return image != null && !image.isDisposed(); } /** * Set the currently displayed image. This method has no effect if the given * image is the same as the currently displayed one. Otherwise, the image is * set to the new one, the zoom is reset to 1.0 and the control is repainted. * The given image may also be {@code null} to clear the display to its * background color. * * @param image * The image, or null. * @param disposeOld * If true the old image is disposed of before * changing. But only if the passed image is not the same as the old * one. */ public void setImage(ObservableImage image, boolean disposeOld) { if (this.image != image) { if (this.image != null && disposeOld) { this.image.dispose(); } setImage(image); } } /** * Returns the current zoom level, which will always be between MIN_ZOOM and * MAX_ZOOM. A zoom level of 1.0 is the original size. * * @return The zoom level. */ public float getZoom() { return scale; } /** * Set the zoom level. The value given must be between MIN_ZOOM and MAX_ZOOM. * If the zoom level is different from the current zoom level, the zoom level * is updated and the image is repainted at the new zoom level. * * @param zoom * The zoom level to set. */ public void setZoom(float zoom) { if (this.scale != zoom) { if (zoom < MIN_ZOOM) { throw new IllegalArgumentException("zoom < MIN_ZOOM"); } if (zoom > MAX_ZOOM) { throw new IllegalArgumentException("zoom > MAX_ZOOM"); } disposeScaledImage(); this.scale = zoom; repaint(); // Notify listeners fireZoomChanged(); } } /** * Returns the current zoom step, i.e. the amount of zoom change that will be * caused by a zoom in or zoom out operation. * * @return The current zoom step. */ public float getZoomStep() { return scaleStep; } /** * Set the current zoom step, i.e. the amount of zoom change that will be * caused by a zoom in or zoom out operation. * * @param zoomStep * The new zoom step (must be {@code > 0}). */ public void setZoomStep(float zoomStep) { if (zoomStep < 0) { throw new IllegalArgumentException("zoomStep < 0"); } this.scaleStep = zoomStep; } /** * Zoom in using the current zoom step. This method will never allow the zoom * to be greater than MAX_ZOOM. */ public void zoomIn() { if (image != null) { float zoom = Math.min(MAX_ZOOM, scale + scaleStep); setZoom(zoom); } } /** * Zoom in using the current zoom step. This method will never allow the zoom * to be less than MIN_ZOOM. */ public void zoomOut() { if (image != null) { float zoom = Math.max(MIN_ZOOM, scale - scaleStep); setZoom(zoom); } } /** * Zoom the image back to it's original size. */ public void zoomOriginal() { setZoom(1.0f); } /** * Zoom the image to it's best fit inside the currently visible scroll area. * The aspect ratio is maintained. */ public void zoomBestFit() { if (image != null) { float zoom = getBestFitScale(); setZoom(zoom); } } /** * Returns true if calling {@link ImageControl#zoomIn()} will have * an effect. * * @return true if zooming in is possible. */ public boolean canZoomIn() { if (image != null) { float zoom = Math.min(MAX_ZOOM, scale + scaleStep); return zoom != scale; } return false; } /** * Returns true if calling {@link ImageControl#zoomOut()} will * have an effect. * * @return true if zooming out is possible. */ public boolean canZoomOut() { if (image != null) { float zoom = Math.max(MIN_ZOOM, scale - scaleStep); return zoom != scale; } return false; } /** * Returns true if calling {@link ImageControl#zoomOriginal()} * will have an effect. * * @return true if zooming to the original size will change the * zoom level. */ public boolean canZoomOriginal() { if (image != null) { return scale != 1.0; } return false; } /** * Returns true if calling {@link ImageControl#zoomBestFit()} will * have an effect. * * @return true if zooming to the "best-fit" will change the * zoom level. */ public boolean canZoomBestFit() { if (image != null) { return scale != getBestFitScale(); } return false; } /** * Force the visible canvas area to repaint itself. */ public void repaint() { recomputeBounds(); Rectangle r = getVisibleCanvasArea(); canvas.redraw(r.x, r.y, r.width, r.height, false); } /** * Force the given part of the image to repaint itself. The image rectangle is * automatically scaled and translated to canvas coordinates. If the image is * null, then the function does nothing. * * @param part * The part of the image repaint. */ public void repaint(Rectangle part) { if (image != null) { recomputeBounds(); Rectangle canv = imageToCanvas(part); Rectangle clip = getVisibleCanvasArea().intersection(canv); canvas.redraw(clip.x, clip.y, clip.width, clip.height, false); } } /** * Returns {@code true} if the given point on the canvas is contained within * the area covered by the image displayed on the canvas. If there is no * image, returns {@code false} * * @param canvasPt * A point in canvas coordinates. * @return true if contained in the image. */ public boolean imageContains(Point canvasPt) { if (image != null) { return bounds.contains(canvasPt); } return false; } /** * Translate a point on the canvas to it's corresponding point on the image * currently that is displayed on the canvas. If there is no current image * set, an {@code IllegalStateException} is thrown. The returned point is at * the original image's scale. * * @param pt * The point to translate. * @return The corresponding point on the image. */ public Point canvasToImage(Point pt) { checkHaveImage(); // translate to image (0,0) int x = pt.x - bounds.x; int y = pt.y - bounds.y; // scale x = (int) Math.floor(x / scale); y = (int) Math.floor(y / scale); return new Point(x, y); } /** * Translate a point on the displayed image (at it's original scale) to the * corresponding point on the canvas. If there is no current image set, an * {@code IllegalStateException} is thrown. * * @param pt * The point on the canvas. * @return The corresponding point on the image. */ public Point imageToCanvas(Point pt) { checkHaveImage(); // scale int x = (int) Math.floor(pt.x * scale); int y = (int) Math.floor(pt.y * scale); // translate x += bounds.x; y += bounds.y; return new Point(x, y); } /** * Translates a rectangle on the image to a rectangle on the canvas. If there * is no current image set, an {@code IllegalStateException} is thrown. If the * given rectangle lies outside the image, only the part that lies inside the * canvas image bounds is returned (the intersection). If it is completely * outside the canvas image bounds, an empty rectangle is returned. * * @param rect * The rectangle to translate. * @return The translated rectangle. */ public Rectangle imageToCanvas(Rectangle rect) { checkHaveImage(); int x = (int) Math.floor(rect.x * scale) + bounds.x; int y = (int) Math.floor(rect.y * scale) + bounds.y; int w = (int) Math.floor(rect.width * scale); int h = (int) Math.floor(rect.height * scale); return new Rectangle(x, y, w, h).intersection(bounds); } /** * Translate a rectangle on the canvas to a rectangle on the image. If there * is no current image set, an {@code IllegalStateException} is thrown. * * @param rect * The rectangle to translate. * @return The translated rectangle. */ public Rectangle canvasToImage(Rectangle rect) { checkHaveImage(); int x = (int) Math.floor((rect.x - bounds.x) / scale); int y = (int) Math.floor((rect.y - bounds.y) / scale); int w = (int) Math.floor(rect.width / scale); int h = (int) Math.floor(rect.height / scale); return new Rectangle(x, y, w, h); } /** * Get the canvas that the image is drawn on. * * @return The canvas. */ public Canvas getCanvas() { return canvas; } /** * Convenience method to set the canvas background color. * * @param color * The color to set. */ public void setCanvasBackground(Color color) { canvas.setBackground(color); } /** * Returns the bounds of the original image, or null if there * is no image. * * @return The bounding rectangle of the image, or null. */ public Rectangle getImageBounds() { if (cachedImageBounds == null) { if (image != null) { cachedImageBounds = image.getBounds(); } } return cachedImageBounds; } /** * Returns the bounds of the image, as currently displayed on the canvas. This * is the original image bounds, translated and scaled onto the image canvas. * Returns null if there is no set image. * * @return The canvas image bounds, or null. */ public Rectangle getCanvasImageBounds() { return bounds; } /** * Add a zoom listener. * * @param listener * The listener to add. */ public void addZoomListener(ZoomListener listener) { listeners.add(listener); } /** * Remove a zoom listener. * * @param listener * The listener to remove. */ public void removeZoomListener(ZoomListener listener) { listeners.remove(listener); } /** * Dispose of the old scaled image (hence invalidating it). */ public void disposeScaledImage() { if (scaled != null) { if (!scaled.isDisposed()) { scaled.dispose(); } scaled = null; } } /** * Returns the scale of the "best-fit" for the currently visible scroll area, * maintaining the aspect ratio and staying within the MAX_ZOOM and MIN_ZOOM * bounds. * * @return The best fit scale. */ private float getBestFitScale() { Rectangle client = scroller.getClientArea(); Rectangle imrect = getImageBounds(); float sx = client.width / (float) imrect.width; float sy = client.height / (float) imrect.height; float zoom = Math.min(sx, sy); if (zoom > MAX_ZOOM) zoom = MAX_ZOOM; if (zoom < MIN_ZOOM) zoom = MIN_ZOOM; return zoom; } /** * Disposes of any old scaled image and constructs a new one. */ private void createScaledImage() { disposeScaledImage(); if (hasImage()) { recomputeBounds(); scaled = new Image(getDisplay(), bounds.width, bounds.height); GC gc = new GC(scaled); Rectangle r = getImageBounds(); gc.drawImage(image.getImage(), 0, 0, r.width, r.height, 0, 0, bounds.width, bounds.height ); gc.dispose(); } } /** * Computes and returns the region on the canvas that is currently visible * in the scroll pane. * * @return The visible canvas area. */ private Rectangle getVisibleCanvasArea() { Rectangle clip = scroller.getClientArea(); Point scrollPos = getScrollbarPosition(); clip.x += scrollPos.x; clip.y += scrollPos.y; return clip; } /** * Get the horizontal and vertical scroll-bar positions. * * @return the scroll-bar position. */ private Point getScrollbarPosition() { int sx = scroller.getHorizontalBar().getSelection(); int sy = scroller.getVerticalBar().getSelection(); return new Point(sx, sy); } /** * Recompute the canvas image bounds and update the scroll bars. */ private void recomputeBounds() { if (image == null) { // No image scroller.setMinSize(0,0); bounds.x = bounds.y = 0; bounds.width = bounds.height = 0; return; } Rectangle imrect = getImageBounds(); Rectangle area = scroller.getClientArea(); // Scale image width and height bounds.width = (int) (imrect.width * scale); bounds.height = (int) (imrect.height * scale); // Center image horizontally if (bounds.width < area.width) { int dx = area.width - bounds.width; bounds.x = dx / 2; } else { bounds.x = 0; } // Center image vertically if (bounds.height < area.height) { int dy = area.height - bounds.height; bounds.y = dy / 2; } else { bounds.y = 0; } // Configure scroll bars scroller.setMinSize(bounds.width, bounds.height); } /** * Returns true if we have an up to date copy of a properly * scaled version of the image. * * @return true if a scaled image is available. */ private boolean hasScaledImage() { return scaled != null && !scaled.isDisposed(); } /** * Throws an {@code IllegalStateException} if there is no image. */ private void checkHaveImage() { if (image == null) { throw new IllegalStateException("image == null"); } } /** * Fire a zoom changed event. */ private void fireZoomChanged() { ZoomEvent evt = null; for (ZoomListener z : listeners) { if (evt == null) { evt = new ZoomEvent(this); } z.zoomChanged(evt); } } private ImageListener imageListener = new ImageListener() { public void imageChanged(ImageEvent e) { handleImageChanged(e); } }; }