/*
* 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.tools;
import org.jdesktop.swingx.combobox.EnumComboBoxModel;
import pixelitor.Composition;
import pixelitor.filters.gui.FilterSetting;
import pixelitor.filters.gui.RangeParam;
import pixelitor.gui.ImageComponent;
import pixelitor.gui.ImageComponents;
import pixelitor.gui.PixelitorWindow;
import pixelitor.gui.utils.OKDialog;
import pixelitor.gui.utils.SliderSpinner;
import pixelitor.layers.Drawable;
import pixelitor.tools.brushes.Brush;
import pixelitor.tools.brushes.BrushAffectedArea;
import pixelitor.tools.brushes.SymmetryBrush;
import pixelitor.utils.ImageSwitchListener;
import pixelitor.utils.VisibleForTesting;
import pixelitor.utils.debug.DebugNode;
import javax.swing.*;
import java.awt.Composite;
import java.awt.Cursor;
import java.awt.Graphics2D;
import java.awt.Point;
import java.awt.Shape;
import java.awt.event.MouseEvent;
import java.awt.geom.FlatteningPathIterator;
import java.awt.geom.PathIterator;
import java.awt.image.BufferedImage;
import static java.awt.RenderingHints.KEY_ANTIALIASING;
import static java.awt.RenderingHints.VALUE_ANTIALIAS_ON;
import static pixelitor.Composition.ImageChangeActions.HISTOGRAM;
import static pixelitor.gui.utils.SliderSpinner.TextPosition.WEST;
/**
* Abstract superclass for tools like brush, erase, clone.
*/
public abstract class AbstractBrushTool extends Tool implements ImageSwitchListener {
private static final int MIN_BRUSH_RADIUS = 1;
public static final int MAX_BRUSH_RADIUS = 100;
public static final int DEFAULT_BRUSH_RADIUS = 10;
private boolean respectSelection = true; // false while tracing a selection
private JComboBox<BrushType> typeSelector;
protected Graphics2D graphics;
private final RangeParam brushRadiusParam = new RangeParam("Radius", MIN_BRUSH_RADIUS, DEFAULT_BRUSH_RADIUS, MAX_BRUSH_RADIUS, false, WEST);
private final EnumComboBoxModel<Symmetry> symmetryModel = new EnumComboBoxModel<>(Symmetry.class);
protected Brush brush;
private SymmetryBrush symmetryBrush;
protected BrushAffectedArea brushAffectedArea;
private boolean firstMouseDown = true; // for the first click don't draw lines even if it is a shift-click
private JButton brushSettingsButton;
DrawStrategy drawStrategy;
AbstractBrushTool(char activationKeyChar, String name, String iconFileName, String toolMessage, Cursor cursor) {
super(activationKeyChar, name, iconFileName, toolMessage,
cursor, true, true, false, ClipStrategy.IMAGE_ONLY);
ImageComponents.addImageSwitchListener(this);
initBrushVariables();
}
protected void initBrushVariables() {
symmetryBrush = new SymmetryBrush(
this, BrushType.values()[0], getSymmetry(), getRadius());
brush = symmetryBrush;
brushAffectedArea = symmetryBrush.getAffectedArea();
}
protected void addTypeSelector() {
typeSelector = new JComboBox<>(BrushType.values());
settingsPanel.addWithLabel("Type:", typeSelector, "brushTypeSelector");
typeSelector.addActionListener(e -> {
closeToolDialog();
BrushType brushType = getBrushType();
symmetryBrush.brushTypeChanged(brushType, getRadius());
brushRadiusParam.setEnabled(brushType.sizeCanBeSet(), FilterSetting.EnabledReason.APP_LOGIC);
brushSettingsButton.setEnabled(brushType.hasSettings());
});
// make sure all values are visible without a scrollbar
typeSelector.setMaximumRowCount(BrushType.values().length);
}
protected void addSizeSelector() {
SliderSpinner brushSizeSelector = (SliderSpinner) brushRadiusParam.createGUI();
settingsPanel.add(brushSizeSelector);
brushRadiusParam.setAdjustmentListener(this::setupDrawingRadius);
setupDrawingRadius();
}
protected void addSymmetryCombo() {
JComboBox<Symmetry> symmetryCombo = new JComboBox<>(symmetryModel);
settingsPanel.addWithLabel("Mirror:", symmetryCombo, "symmetrySelector");
symmetryCombo.addActionListener(e -> symmetryBrush.symmetryChanged(
getSymmetry(), getRadius()));
}
protected void addBrushSettingsButton() {
brushSettingsButton = settingsPanel.addButton("Brush Settings",
e -> {
BrushType brushType = getBrushType();
JPanel p = brushType.getSettingsPanel(this);
toolDialog = new OKDialog(PixelitorWindow.getInstance(), p, "Brush Settings");
});
brushSettingsButton.setEnabled(false);
}
@Override
public void mousePressed(MouseEvent e, ImageComponent ic) {
boolean withLine = withLine(e);
double x = userDrag.getStartX();
double y = userDrag.getStartY();
newMousePoint(ic.getComp().getActiveDrawable(), x, y, withLine);
firstMouseDown = false;
if (withLine) {
brushAffectedArea.updateAffectedCoordinates(x, y);
} else {
brushAffectedArea.initAffectedCoordinates(x, y);
}
}
protected boolean withLine(MouseEvent e) {
return !firstMouseDown && e.isShiftDown();
}
@Override
public void mouseDragged(MouseEvent e, ImageComponent ic) {
double x = userDrag.getEndX();
double y = userDrag.getEndY();
// at this point x and y are already scaled according to the zoom level
// (unlike e.getX(), e.getY())
newMousePoint(ic.getComp().getActiveDrawable(), x, y, false);
}
@Override
public void mouseReleased(MouseEvent e, ImageComponent ic) {
if (graphics == null) {
// we can get here if the mousePressed was an Alt-press, therefore
// consumed by the color picker. Nothing was drawn, therefore
// there is no need to save a backup, we can just return
// TODO is this true after all the refactorings?
return;
}
Composition comp = ic.getComp();
finishBrushStroke(comp.getActiveDrawable());
}
private void finishBrushStroke(Drawable dr) {
int radius = getRadius();
ToolAffectedArea affectedArea = new ToolAffectedArea(dr,
brushAffectedArea.getRectangleAffectedByBrush(radius), false);
BufferedImage originalImage = drawStrategy.getOriginalImage(dr, this);
saveSubImageForUndo(originalImage, affectedArea);
if (graphics != null) {
graphics.dispose();
}
graphics = null;
drawStrategy.finishBrushStroke(dr);
dr.updateIconImage();
dr.getComp().imageChanged(HISTOGRAM);
}
public void drawBrushStrokeProgrammatically(Drawable dr, Point start, Point end) {
prepareProgrammaticBrushStroke(dr, start);
brush.onDragStart(start.x, start.y);
brush.onNewMousePoint(end.x, end.y);
finishBrushStroke(dr);
}
protected void prepareProgrammaticBrushStroke(Drawable dr, Point start) {
drawStrategy.prepareBrushStroke(dr);
graphics = createGraphicsForNewBrushStroke(dr);
}
/**
* Creates the global Graphics2D object graphics.
*/
private Graphics2D createGraphicsForNewBrushStroke(Drawable dr) {
Composition comp = dr.getComp();
Composite composite = getComposite();
Graphics2D g = drawStrategy.createDrawGraphics(dr, composite);
initializeGraphics(g);
if (respectSelection) {
comp.applySelectionClipping(g, null);
}
brush.setTarget(comp, g);
return g;
}
/**
* An opportunity to do extra tool-specific
* initializations in the subclasses
*/
protected void initializeGraphics(Graphics2D g) {
}
// overridden in brush tools with blending mode
protected Composite getComposite() {
return null;
}
/**
* Called from mousePressed, mouseDragged
*/
private void newMousePoint(Drawable dr, double x, double y, boolean connectClickWithLine) {
if (graphics == null) { // a new brush stroke has to be initialized
drawStrategy.prepareBrushStroke(dr);
graphics = createGraphicsForNewBrushStroke(dr);
graphics.setRenderingHint(KEY_ANTIALIASING, VALUE_ANTIALIAS_ON);
if (connectClickWithLine) {
brush.onNewMousePoint(x, y);
} else {
brush.onDragStart(x, y);
}
} else {
brush.onNewMousePoint(x, y);
}
}
private void setupDrawingRadius() {
int newRadius = getRadius();
brush.setRadius(newRadius);
// int desiredImgSize = 2 * newRadius;
// Dimension cursorSize = Toolkit.getDefaultToolkit().getBestCursorSize(desiredImgSize, desiredImgSize);
//
// BufferedImage cursorImage = ImageUtils.createSysCompatibleImage(cursorSize.width, cursorSize.height);
// Graphics2D g = cursorImage.createGraphics();
// g.setRenderingHint(KEY_ANTIALIASING, VALUE_ANTIALIAS_ON);
// g.setColor(Color.RED);
// g.drawOval(0, 0, cursorSize.width, cursorSize.height);
// g.dispose();
//
// cursor = Toolkit.getDefaultToolkit().createCustomCursor(
// cursorImage,
// new Point(cursorSize.width / 2, cursorSize.height / 2), "brush");
// ImageComponents.onAllImages(ic -> ic.setCursor(cursor));
}
@Override
protected void toolStarted() {
super.toolStarted();
resetState();
}
@Override
public void noOpenImageAnymore() {
}
@Override
public void newImageOpened(Composition comp) {
resetState();
}
@Override
public void activeImageHasChanged(ImageComponent oldIC, ImageComponent newIC) {
resetState();
}
private void resetState() {
firstMouseDown = true;
respectSelection = true;
}
/**
* Traces the given shape with the current brush tool
*/
public void trace(Drawable dr, Shape shape) {
try {
respectSelection = false;
drawStrategy.prepareBrushStroke(dr);
graphics = createGraphicsForNewBrushStroke(dr);
doTraceAfterSetup(shape);
finishBrushStroke(dr);
} finally {
resetState();
}
}
private void doTraceAfterSetup(Shape shape) {
int startingX = 0;
int startingY = 0;
PathIterator fpi = new FlatteningPathIterator(shape.getPathIterator(null), 1.0);
float[] coords = new float[2];
while (!fpi.isDone()) {
int type = fpi.currentSegment(coords);
int x = (int) coords[0];
int y = (int) coords[1];
brushAffectedArea.updateAffectedCoordinates(x, y);
switch (type) {
case PathIterator.SEG_MOVETO:
startingX = x;
startingY = y;
brush.onDragStart(x, y);
break;
case PathIterator.SEG_LINETO:
brush.onNewMousePoint(x, y);
break;
case PathIterator.SEG_CLOSE:
brush.onNewMousePoint(startingX, startingY);
break;
default:
throw new IllegalArgumentException("type = " + type);
}
fpi.next();
}
}
public void increaseBrushSize() {
brushRadiusParam.increaseValue();
}
public void decreaseBrushSize() {
brushRadiusParam.decreaseValue();
}
protected Symmetry getSymmetry() {
return symmetryModel.getSelectedItem();
}
protected int getRadius() {
int value = brushRadiusParam.getValue();
// because of a JDK bug, sometimes it is possible to drag the slider to negative values
if (value < MIN_BRUSH_RADIUS) {
value = MIN_BRUSH_RADIUS;
brushRadiusParam.setValue(MIN_BRUSH_RADIUS);
}
return value;
}
@Override
protected boolean doColorPickerForwarding() {
return true;
}
@VisibleForTesting
protected Brush getBrush() {
return brush;
}
@VisibleForTesting
protected void setBrush(Brush brush) {
this.brush = brush;
}
private BrushType getBrushType() {
return (BrushType) typeSelector.getSelectedItem();
}
@Override
public DebugNode getDebugNode() {
DebugNode node = super.getDebugNode();
if (typeSelector != null) { // can be null, for example in Clone
node.addStringChild("Brush Type", getBrushType().toString());
}
node.addIntChild("Radius", getRadius());
node.add(brush.getDebugNode());
if (symmetryBrush != null) { // can be null, for example in Clone
node.addStringChild("Symmetry", getSymmetry().toString());
if (symmetryBrush != brush) {
node.add(symmetryBrush.getDebugNode());
}
}
return node;
}
}