/*
* Copyright 2017 Laszlo Balazs-Csiki
*
* This file is part of Pixelitor. Pixelitor is free software: you
* can redistribute it and/or modify it under the terms of the GNU
* General Public License, version 3 as published by the Free
* Software Foundation.
*
* Pixelitor 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 Pixelitor. If not, see <http://www.gnu.org/licenses/>.
*/
package pixelitor.gui;
import org.jdesktop.swingx.painter.CheckerboardPainter;
import pixelitor.AppLogic;
import pixelitor.Canvas;
import pixelitor.Composition;
import pixelitor.ConsistencyChecks;
import pixelitor.gui.utils.Dialogs;
import pixelitor.history.CompositionReplacedEdit;
import pixelitor.history.DeselectEdit;
import pixelitor.history.LinkedEdit;
import pixelitor.history.PixelitorEdit;
import pixelitor.layers.Layer;
import pixelitor.layers.LayerButton;
import pixelitor.layers.LayerMask;
import pixelitor.layers.LayersContainer;
import pixelitor.layers.LayersPanel;
import pixelitor.layers.MaskViewMode;
import pixelitor.menus.view.ZoomControl;
import pixelitor.menus.view.ZoomLevel;
import pixelitor.tools.Tool;
import pixelitor.tools.Tools;
import pixelitor.utils.ImageUtils;
import pixelitor.utils.Messages;
import pixelitor.utils.Utils;
import pixelitor.utils.debug.ImageComponentNode;
import javax.swing.*;
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.Shape;
import java.awt.event.ComponentAdapter;
import java.awt.event.ComponentEvent;
import java.awt.event.MouseEvent;
import java.awt.event.MouseListener;
import java.awt.event.MouseMotionListener;
import java.awt.geom.AffineTransform;
import java.awt.geom.NoninvertibleTransformException;
import java.awt.geom.Rectangle2D;
import java.awt.image.BufferedImage;
import java.beans.PropertyVetoException;
import static java.awt.Color.BLACK;
/**
* The GUI component that shows a composition
*/
public class ImageComponent extends JComponent implements MouseListener, MouseMotionListener {
private double viewScale = 1.0f;
private Canvas canvas;
private ZoomLevel zoomLevel = ZoomLevel.Z100;
private ImageFrame frame = null;
private static final CheckerboardPainter checkerBoardPainter = ImageUtils.createCheckerboardPainter();
private LayersPanel layersPanel;
private Composition comp;
private MaskViewMode maskViewMode;
// the start of the image if the ImageComponent is resized to bigger
// than the canvas, and the image needs to be centralized
private double drawStartX;
private double drawStartY;
private Navigator navigator;
public static boolean showPixelGrid = false;
public ImageComponent(Composition comp) {
assert comp != null;
this.comp = comp;
this.canvas = comp.getCanvas();
comp.setIC(this);
ZoomLevel fitZoom = Desktop.calcFitScreenZoom(canvas.getWidth(), canvas.getHeight(), false);
setZoom(fitZoom, true, null);
layersPanel = new LayersPanel();
addListeners();
}
public PixelitorEdit replaceComp(Composition newComp, boolean addToHistory, MaskViewMode newMaskViewMode) {
assert newComp != null;
PixelitorEdit edit = null;
MaskViewMode oldMode = maskViewMode;
Composition oldComp = comp;
comp = newComp;
// do this here so that the old comp is deselected before
// its ic is set to null
if (addToHistory) {
PixelitorEdit replaceEdit = new CompositionReplacedEdit(
"Reload", this, oldComp, newComp, oldMode);
if (oldComp.hasSelection()) {
DeselectEdit deselectEdit = oldComp.createDeselectEdit();
edit = new LinkedEdit(oldComp, "Reload", deselectEdit, replaceEdit);
oldComp.deselect(false);
} else {
edit = replaceEdit;
}
}
oldComp.setIC(null);
comp.setIC(this);
canvas = newComp.getCanvas();
// keep the zoom level, but reinitialize the
// internal frame size
setZoom(zoomLevel, true, null);
// refresh the layer buttons
layersPanel = new LayersPanel();
comp.addLayersToGUI();
LayersContainer.showLayersPanel(layersPanel);
newMaskViewMode.activate(this, comp.getActiveLayer());
updateNavigator(true);
return edit;
}
private void addListeners() {
addMouseListener(this);
addMouseMotionListener(this);
addMouseWheelListener(e -> {
if (e.isControlDown()) {
if (e.getWheelRotation() < 0) { // up, away from the user
increaseZoom(e.getPoint());
} else { // down, towards the user
decreaseZoom(e.getPoint());
}
}
});
// make sure that the image is drawn at the middle
addComponentListener(new ComponentAdapter() {
@Override
public void componentResized(ComponentEvent e) {
updateDrawStart();
if (Tools.getCurrent() == Tools.CROP) {
Tools.CROP.icResized(ImageComponent.this);
}
repaint();
}
});
}
@Override
public Dimension getPreferredSize() {
if (comp.isEmpty()) {
return super.getPreferredSize();
} else {
return canvas.getZoomedSize();
}
}
@Override
public void mouseClicked(MouseEvent e) {
Tools.EventDispatcher.mouseClicked(e, this);
}
@Override
public void mouseEntered(MouseEvent e) {
// mouseEntered is never used in the tools
}
@Override
public void mouseExited(MouseEvent e) {
// mouseExited is never used in the tools
}
@Override
public void mousePressed(MouseEvent e) {
Tools.EventDispatcher.mousePressed(e, this);
}
@Override
public void mouseReleased(MouseEvent e) {
Tools.EventDispatcher.mouseReleased(e, this);
}
@Override
public void mouseDragged(MouseEvent e) {
Tools.EventDispatcher.mouseDragged(e, this);
}
@Override
public void mouseMoved(MouseEvent e) {
Tools.EventDispatcher.mouseMoved(e, this);
}
public void setFrame(ImageFrame frame) {
this.frame = frame;
}
public ImageFrame getFrame() {
return frame;
}
public void close() {
if (frame != null) {
// this will also cause the calling of AppLogic.imageClosed via
// InternalImageFrame.internalFrameClosed
frame.dispose();
}
comp.dispose();
}
public void onActivation() {
try {
getFrame().setSelected(true);
} catch (PropertyVetoException e) {
Messages.showException(e);
}
LayersContainer.showLayersPanel(layersPanel);
}
public double getViewScale() {
return viewScale;
}
public void updateTitle() {
if (frame != null) {
String frameTitle = createFrameTitle();
frame.setTitle(frameTitle);
}
}
public String createFrameTitle() {
return comp.getName() + " - " + zoomLevel.toString();
}
public ZoomLevel getZoomLevel() {
return zoomLevel;
}
public void deleteLayerButton(LayerButton button) {
layersPanel.deleteLayerButton(button);
}
public Composition getComp() {
return comp;
}
public void changeLayerOrderInTheGUI(int oldIndex, int newIndex) {
layersPanel.changeLayerOrderInTheGUI(oldIndex, newIndex);
}
@Override
public void paint(Graphics g) {
try {
// no borders, no children, double-buffering is happening
// in the parent
paintComponent(g);
} catch (OutOfMemoryError e) {
Dialogs.showOutOfMemoryDialog(e);
}
}
@Override
public void paintComponent(Graphics g) {
Shape originalClip = g.getClip();
Graphics2D g2 = (Graphics2D) g;
int zoomedWidth = canvas.getZoomedWidth();
int zoomedHeight = canvas.getZoomedHeight();
Rectangle imageClip = adjustClipBoundsForImage(g, drawStartX, drawStartY, zoomedWidth, zoomedHeight);
AffineTransform unscaledTransform = g2.getTransform(); // a copy of the transform object
g2.translate(drawStartX, drawStartY);
boolean showMask = maskViewMode.showMask();
if (!showMask) {
checkerBoardPainter.paint(g2, this, zoomedWidth, zoomedHeight);
}
g2.scale(viewScale, viewScale);
if (showMask) {
LayerMask mask = comp.getActiveLayer().getMask();
assert mask != null : "no mask in " + maskViewMode;
mask.paintLayerOnGraphics(g2, true);
} else {
BufferedImage drawnImage = comp.getCompositeImage();
ImageUtils.drawImageWithClipping(g2, drawnImage);
if (maskViewMode.showRuby()) {
LayerMask mask = comp.getActiveLayer().getMask();
assert mask != null : "no mask in " + maskViewMode;
mask.paintAsRubylith(g2);
}
}
// possibly allow a larger clip for the selections and tools
Tool currentTool = Tools.getCurrent();
currentTool.setClip(g2, this);
comp.paintSelection(g2);
currentTool.paintOverImage(g2, canvas, this, unscaledTransform);
// restore original transform
g2.setTransform(unscaledTransform);
g2.setClip(imageClip);
// draw pixel grid
if (showPixelGrid && zoomLevel.drawPixelGrid() && !comp.hasSelection()) {
// TODO why is this very slow if there is selection?
g2.setXORMode(BLACK);
double pixelSize = zoomLevel.getViewScale();
// assert pixelSize > 0;
// System.out.println("ImageComponent::paintComponent: START zoomLevel = " + zoomLevel
// + ", pixelSize = " + pixelSize
// + ", width = " + zoomedWidth + ", height = " + zoomedHeight
// + ", comp = " + comp.getName());
// long startTime = System.nanoTime();
int startX = (int) this.drawStartX;
int startY = (int) this.drawStartY;
int endX = zoomedWidth + startX;
int endY = zoomedHeight + startY;
// vertical lines
for (double i = pixelSize; i < zoomedWidth; i += pixelSize) {
int x = (int) (drawStartX + i);
g2.drawLine(x, startY, x, endY);
}
// horizontal lines
for (double i = pixelSize; i < zoomedHeight; i += pixelSize) {
int y = (int) (drawStartY + i);
g2.drawLine(startX, y, endX, y);
}
// double estimatedSeconds = (System.nanoTime() - startTime) / 1_000_000_000.0;
// System.out.println(String.format("ImageComponent::paintComponent: FINISHED estimatedSeconds = '%.2f'", estimatedSeconds));
}
g2.setClip(originalClip);
}
/**
* Makes sure that not the whole area is repainted, only the image
*/
private static Rectangle adjustClipBoundsForImage(Graphics g, double drawStartX, double drawStartY, int maxWidth, int maxHeight) {
Rectangle clipBounds = g.getClipBounds();
Rectangle imageRect = new Rectangle((int) drawStartX, (int) drawStartY, maxWidth, maxHeight);
clipBounds = clipBounds.intersection(imageRect);
g.setClip(clipBounds);
return clipBounds;
}
/**
* Repaints only a region of the image, called from the brush tools
*/
public void updateRegion(double startX, double startY, double endX, double endY, int thickness) {
if (zoomLevel != ZoomLevel.Z100) { // not the 100% view
startX = (int) (drawStartX + viewScale * startX);
startY = (int) (drawStartY + viewScale * startY);
endX = (int) (drawStartX + viewScale * endX);
endY = (int) (drawStartY + viewScale * endY);
thickness = (int) (viewScale * thickness);
} else { // drawStartX drawStartY has to be adjusted anyway
startX = (int) (drawStartX + startX);
startY = (int) (drawStartY + startY);
endX = (int) (drawStartX + endX);
endY = (int) (drawStartY + endY);
}
if (endX < startX) {
double tmp = startX;
startX = endX;
endX = tmp;
}
if (endY < startY) {
double tmp = startY;
startY = endY;
endY = tmp;
}
startX -= thickness;
endX += thickness;
startY -= thickness;
endY += thickness;
double repWidth = endX - startX;
double repHeight = endY - startY;
repaint((int) startX, (int) startY, (int) repWidth, (int) repHeight);
}
public void makeSureItIsVisible() {
if (frame != null) {
frame.makeSureItIsVisible();
}
}
public MaskViewMode getMaskViewMode() {
return maskViewMode;
}
public boolean setMaskViewMode(MaskViewMode maskViewMode) {
// it is important not to call this directly,
// it should be a part of a mask activation
assert Utils.callingClassIs("MaskViewMode");
assert maskViewMode.checkOnAssignment(comp.getActiveLayer());
MaskViewMode oldMode = this.maskViewMode;
this.maskViewMode = maskViewMode;
boolean change = oldMode != maskViewMode;
if (change) {
repaint();
}
return change;
}
public void canvasSizeChanged() {
assert ConsistencyChecks.imageCoversCanvasCheck(comp);
if (frame != null) {
frame.setSize(canvas.getZoomedWidth(), canvas.getZoomedHeight(), -1, -1);
}
revalidate();
}
public Canvas getCanvas() {
return canvas;
}
public void zoomToFitScreen() {
BufferedImage image = comp.getCompositeImage();
int width = image.getWidth();
int height = image.getHeight();
ZoomLevel fitZoom = Desktop.calcFitScreenZoom(width, height, true);
setZoom(fitZoom, false, null);
}
/**
* Sets the new zoom level
*/
public void setZoom(ZoomLevel newZoom, boolean forceSettingSize, Point mousePos) {
ZoomLevel oldZoom = zoomLevel;
if (oldZoom == newZoom && !forceSettingSize) {
// forceSettingSize is set at initial creation and at F5 reload
return;
}
this.zoomLevel = newZoom;
viewScale = newZoom.getViewScale();
canvas.updateForZoom(viewScale);
Rectangle areaThatShouldBeVisible = null;
if (frame != null) {
updateTitle();
frame.setSize(canvas.getZoomedWidth(), canvas.getZoomedHeight(), -1, -1);
Rectangle viewRect = getViewRect();
// Update the scrollbars.
Point origin;
if (mousePos != null) { // we had a mouse click
origin = mousePos;
} else {
int cx = viewRect.x + viewRect.width / 2;
int cy = viewRect.y + viewRect.height / 2;
origin = new Point(cx, cy);
}
// the x, y coordinates were generated BEFORE the zooming
// so we need to find the corresponding coordinates after zooming
// TODO maybe this would not be necessary if we did this earlier?
Point imageSpaceOrigin = fromComponentToImageSpace(origin, oldZoom);
origin = fromImageToComponentSpace(imageSpaceOrigin, newZoom);
areaThatShouldBeVisible = new Rectangle(
origin.x - viewRect.width / 2,
origin.y - viewRect.height / 2,
viewRect.width,
viewRect.height
);
}
revalidate();
Rectangle finalRect = areaThatShouldBeVisible;
// TODO is this necessary? - could call validate instead of revalidate
// some flickering is present either way
// we are already on the EDT, but we want to call this code
// only after all pending AWT events have been processed
// because then this component will have the final size
// and updateDrawStart can calculate correct results
SwingUtilities.invokeLater(() -> {
updateDrawStart();
if (finalRect != null) {
scrollRectToVisible(finalRect);
}
repaint();
});
if (ImageComponents.getActiveIC() == this) {
ZoomControl.INSTANCE.setToNewZoom(zoomLevel);
zoomLevel.getMenuItem().setSelected(true);
}
}
public void setZoomAtCenter(ZoomLevel newZoom) {
setZoom(newZoom, false, null);
}
public void increaseZoom() {
increaseZoom(null);
}
public void increaseZoom(Point mousePos) {
ZoomLevel newZoom = zoomLevel.zoomIn();
setZoom(newZoom, false, mousePos);
}
public void decreaseZoom() {
decreaseZoom(null);
}
public void decreaseZoom(Point mousePos) {
ZoomLevel newZoom = zoomLevel.zoomOut();
setZoom(newZoom, false, mousePos);
}
public void updateDrawStart() {
int width = getWidth();
int canvasZoomedWidth = canvas.getZoomedWidth();
int height = getHeight();
int canvasZoomedHeight = canvas.getZoomedHeight();
drawStartX = (width - canvasZoomedWidth) / 2.0;
drawStartY = (height - canvasZoomedHeight) / 2.0;
}
public double componentXToImageSpace(int mouseX) {
return ((mouseX - drawStartX) / viewScale);
}
public double componentYToImageSpace(int mouseY) {
return ((mouseY - drawStartY) / viewScale);
}
public int imageXToComponentSpace(double mouseX) {
return (int) (drawStartX + mouseX * viewScale);
}
public int imageYToComponentSpace(double mouseY) {
return (int) (drawStartY + mouseY * viewScale);
}
public Point fromComponentToImageSpace(Point input, ZoomLevel zoom) {
double zoomViewScale = zoom.getViewScale();
return new Point(
(int) ((input.x - drawStartX) / zoomViewScale),
(int) ((input.y - drawStartY) / zoomViewScale)
);
}
public Point fromImageToComponentSpace(Point input, ZoomLevel zoom) {
double zoomViewScale = zoom.getViewScale();
return new Point(
(int) (drawStartX + input.x * zoomViewScale),
(int) (drawStartY + input.y * zoomViewScale)
);
}
public Rectangle2D fromComponentToImageSpace(Rectangle input) {
return new Rectangle.Double(
componentXToImageSpace(input.x),
componentYToImageSpace(input.y),
(input.getWidth() / viewScale),
(input.getHeight() / viewScale)
);
}
public Rectangle fromImageToComponentSpace(Rectangle2D input) {
return new Rectangle(
imageXToComponentSpace(input.getX()),
imageYToComponentSpace(input.getY()),
(int) (input.getWidth() * viewScale),
(int) (input.getHeight() * viewScale)
);
}
// TODO untested
public AffineTransform getImageToComponentTransform() {
AffineTransform t = new AffineTransform();
t.translate(drawStartX, drawStartY);
t.scale(viewScale, viewScale);
return t;
}
// TODO untested
public AffineTransform getComponentToImageTransform() {
AffineTransform inverse = null;
try {
inverse = getImageToComponentTransform().createInverse();
} catch (NoninvertibleTransformException e) {
// should not happen
e.printStackTrace();
}
return inverse;
}
/**
* Returns how much of this ImageComponent is currently visible considering that
* the JScrollPane might show only a part of it
*/
public Rectangle getViewRect() {
return frame.getScrollPane().getViewport().getViewRect();
}
public void addLayerToGUI(Layer newLayer, int newLayerIndex) {
LayerButton layerButton = newLayer.getUI().getLayerButton();
layersPanel.addLayerButton(layerButton, newLayerIndex);
if (ImageComponents.isActive(this)) {
AppLogic.activeCompLayerCountChanged(comp, comp.getNumLayers());
}
}
public boolean activeIsDrawable() {
return comp.activeIsDrawable();
}
/**
* The return value is changed only in unit tests
*/
@SuppressWarnings({"MethodMayBeStatic", "SameReturnValue"})
public boolean isMock() {
return false;
}
public LayersPanel getLayersPanel() {
return layersPanel;
}
public void setNavigator(Navigator navigator) {
this.navigator = navigator;
}
public void updateNavigator(boolean newICSize) {
if (navigator != null) {
SwingUtilities.invokeLater(() ->
navigator.refreshSizeCalc(this, false, newICSize, false));
}
}
@Override
public String toString() {
ImageComponentNode node = new ImageComponentNode("ImageComponent", this);
return node.toDetailedString();
}
}