/*
* Copyright (c) 2008, SQL Power Group Inc.
*
* This file is part of Wabit.
*
* Wabit 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.
*
* Wabit 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/>.
*/
package ca.sqlpower.swingui.querypen;
import java.awt.BasicStroke;
import java.awt.Rectangle;
import java.awt.geom.PathIterator;
import java.awt.geom.Point2D;
import java.awt.geom.Rectangle2D;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.util.Collection;
import javax.swing.ImageIcon;
import javax.swing.JEditorPane;
import javax.swing.UIManager;
import javax.swing.text.StyleConstants;
import org.apache.log4j.Logger;
import ca.sqlpower.query.SQLJoin;
import ca.sqlpower.query.SQLJoin.Comparators;
import edu.umd.cs.piccolo.PCanvas;
import edu.umd.cs.piccolo.PNode;
import edu.umd.cs.piccolo.event.PBasicInputEventHandler;
import edu.umd.cs.piccolo.event.PInputEvent;
import edu.umd.cs.piccolo.event.PInputEventListener;
import edu.umd.cs.piccolo.nodes.PImage;
import edu.umd.cs.piccolo.nodes.PPath;
import edu.umd.cs.piccolo.nodes.PText;
import edu.umd.cs.piccolo.util.PBounds;
import edu.umd.cs.piccolo.util.PPickPath;
import edu.umd.cs.piccolox.event.PNotification;
import edu.umd.cs.piccolox.event.PNotificationCenter;
import edu.umd.cs.piccolox.event.PSelectionEventHandler;
import edu.umd.cs.piccolox.nodes.PStyledText;
/**
* This object draws a join line between two columns in the GUI query pen.
*/
public class JoinLine extends PNode implements CleanupPNode {
private static Logger logger = Logger.getLogger(JoinLine.class);
/**
* The border width of the ellipse that surrounds the join expression.
*/
private static final float BORDER_WIDTH = 5;
/**
* This is the minimum amount the join line will stick out of the container
* PNode. This will help the user see where the join lines come out of and
* where they go to when the join line goes behind the connected container
* PNode.
*/
private static final float JOIN_LINE_STICKOUT_LENGTH = 50;
/**
* A buffer that defines how far from the mouse click we will consider the
* user actually meant to click on a specific part of the join line.
*/
private static final int MOUSE_CLICK_BUFFER = 4;
/**
* The length of each dash that makes up a join line part when it is in
* outer join mode.
*/
private static final float DASH_WIDTH = 5;
/**
* One of the columns that is being joined on.
*/
private UnmodifiableItemPNode leftNode;
/**
* The other column that is being joined on.
*/
private UnmodifiableItemPNode rightNode;
/**
* The parent to the leftNode. This will be used to know where
* to draw the join line and when to update it on a move.
*/
private final PNode leftContainerPane;
/**
* The parent to the rightNode. This will be used to know where
* to draw the join line and when to update it on a move.
*/
private final PNode rightContainerPane;
/**
* The text of the type of join the two columns are being joined by.
*/
private final PStyledText symbolText;
/**
* A circle to surround the join text.
*/
private final PPath textCircle;
/**
* A box containing the optionSigns
*/
private final PNode optionBox;
private JEditorPane editorPane;
/**
* check if two joined tables are swapped
*/
private boolean isJoinedTablesSwapped;
/**
* previous isJoinedTablesSwapped
*/
private boolean oldIsJoinedTableSwapped;
/**
* the comparator for viewing in the circle of the Joined line
*/
private String viewCom;
/**
* A Bezier curve that connects the left column to the text circle.
*/
private final PPath leftPath;
/**
* A Bezier curve that connects the right column to the text circle.
*/
private final PPath rightPath;
private final PNode joinCombo;
private boolean clickedOnLeftPath;
/**
* This will listen for right clicks and if the click is near the join
* line it will pop-up a list of join types (inner and outer) to let
* the user change the join type.
*/
private final PInputEventListener joinChangeListener = new PBasicInputEventHandler() {
@Override
public void mouseReleased(PInputEvent event) {
maybeShowPopup(event);
}
@Override
public void mousePressed(PInputEvent event) {
maybeShowPopup(event);
}
@Override
public void mouseClicked(PInputEvent event) {
maybeShowPopup(event);
}
private void maybeShowPopup(PInputEvent event) {
if (event.isPopupTrigger()) {
optionBox.translate(event.getPosition().getX() - optionBox.getFullBounds().getX() - BORDER_WIDTH, event.getPosition().getY() - optionBox.getFullBounds().getY() - BORDER_WIDTH);
if (checkClickOnPath(event.getPosition().getX(), event.getPosition().getY(), textCircle)) {
canvas.getLayer().addChild(optionBox);
logger.debug("Clicked on textCircle");
return;
}
if (canvas.getLayer().getAllNodes().contains(optionBox)) {
canvas.getLayer().removeChild(optionBox);
}
joinCombo.translate(event.getPosition().getX() - joinCombo.getFullBounds().getX() - BORDER_WIDTH, event.getPosition().getY() - joinCombo.getFullBounds().getY() - BORDER_WIDTH);
if (canvas.getLayer().getAllNodes().contains(joinCombo)) {
canvas.getLayer().removeChild(joinCombo);
}
if (checkClickOnPath(event.getPosition().getX(), event.getPosition().getY(), leftPath)) {
clickedOnLeftPath = true;
canvas.getLayer().addChild(joinCombo);
logger.debug("Clicked on left path");
return;
}
if (checkClickOnPath(event.getPosition().getX(), event.getPosition().getY(), rightPath)) {
clickedOnLeftPath = false;
canvas.getLayer().addChild(joinCombo);
logger.debug("Clicked on right path");
}
}
};
};
/**
* The canvas to display combo boxes and this join on.
*/
private final PCanvas canvas;
/**
* This is the model behind the JoinLine
*/
private SQLJoin model;
/**
* This join listener will update the view when the model changes.
*/
private final PropertyChangeListener joinListener = new PropertyChangeListener() {
public void propertyChange(PropertyChangeEvent evt) {
updateLine();
}
};
private final QueryPen queryPen;
/**
* This join icon will be displayed when the join is first created and when it is in
* an equality. If the two columns in the join is not being equated on a circle with
* the comparator will be show instead.
* <p>
* This icon is for when the join is selected.
*/
private final ImageIcon joinSelectedIcon;
/**
* This join icon will be displayed when the join is first created and when it is in
* an equality. If the two columns in the join is not being equated on a circle with
* the comparator will be show instead.
* <p>
* This icon is for when the join is selected.
*/
private final ImageIcon joinUnselectedIcon;
/**
* This is the PNode wrapper to the JoinIcon. This contains the selected image.
*/
private PImage selectedImageNode;
/**
* This is the PNode wrapper to the JoinIcon. This contains the unselected image.
*/
private PImage unselectedImageNode;
/**
* This will create a join line with properties taken from the model. The ItemPNodes passed in must
* contain the left and right items respectively so the JoinLine can connect itself correctly. Failing
* to pass in the correct ItemPNodes will result in an IllegalStateException.
*/
public JoinLine(QueryPen queryPen, PCanvas c, SQLJoin joinModel) throws IllegalStateException {
super();
Collection<PNode> allNodes = (Collection<PNode>) queryPen.getTopLayer().getAllNodes();
leftNode = null;
rightNode = null;
for (PNode node : allNodes) {
if (node instanceof UnmodifiableItemPNode) {
UnmodifiableItemPNode pnode = (UnmodifiableItemPNode) node;
if (leftNode == null && pnode.getModel() == joinModel.getLeftColumn()) {
leftNode = pnode;
} else if (rightNode == null && pnode.getModel() == joinModel.getRightColumn()) {
rightNode = pnode;
}
if (leftNode != null && rightNode != null) break;
}
}
if (leftNode == null) {
throw new IllegalStateException("The view and model are inconsistent. Could not find a view component for " + joinModel.getLeftColumn());
}
if (rightNode == null) {
throw new IllegalStateException("The view and model are inconsistent. Could not find a view component for " + joinModel.getRightColumn());
}
this.model = joinModel;
this.queryPen = queryPen;
this.canvas = c;
model.addJoinChangeListener(joinListener);
leftNode.JoinTo(this);
rightNode.JoinTo(this);
leftContainerPane = leftNode.getParent();
rightContainerPane = rightNode.getParent();
leftContainerPane.addPropertyChangeListener(new PropertyChangeListener() {
public void propertyChange(PropertyChangeEvent evt) {
updateLine();
}
});
rightContainerPane.addPropertyChangeListener(new PropertyChangeListener() {
public void propertyChange(PropertyChangeEvent evt) {
updateLine();
}
});
leftPath = new PPath();
addChild(leftPath);
leftPath.setStroke(new BasicStroke(2));
rightPath = new PPath();
addChild(rightPath);
rightPath.setStroke(new BasicStroke(2));
joinSelectedIcon = new ImageIcon(JoinLine.class.getClassLoader().getResource("ca/sqlpower/swingui/querypen/node_on.png"));
joinUnselectedIcon = new ImageIcon(JoinLine.class.getClassLoader().getResource("ca/sqlpower/swingui/querypen/node_off.png"));
selectedImageNode = new PImage(joinSelectedIcon.getImage());
unselectedImageNode = new PImage(joinUnselectedIcon.getImage());
addChild(selectedImageNode);
addChild(unselectedImageNode);
selectedImageNode.setVisible(false);
textCircle = PPath.createEllipse(0, 0, 0, 0);
addChild(textCircle);
textCircle.setStroke(new BasicStroke(2));
oldIsJoinedTableSwapped = false;
viewCom = "=";
isJoinedTablesSwapped = false;
optionBox = new PNode();
symbolText = new PStyledText();
editorPane = new JEditorPane();
editorPane.setText("=");
symbolText.setDocument(editorPane.getDocument());
int textHeight = 0;
for (Comparators aComparator: Comparators.values()) {
final PText tempText = new PText(aComparator.getComparator());
tempText.translate(0, textHeight);
tempText.addInputEventListener(new PBasicInputEventHandler() {
public void mousePressed(PInputEvent event) {
viewCom = tempText.getText();
editorPane.setText(viewCom);
symbolText.syncWithDocument();
if (isJoinedTablesSwapped ) {
model.setComparator(getOppositeSymbol(viewCom));
} else {
model.setComparator(viewCom );
}
canvas.getLayer().removeChild(optionBox);
updateLine();
}
});
optionBox.addChild(tempText);
textHeight += tempText.getHeight();
}
PPath optionBoxouterRect = PPath.createRectangle((float)- BORDER_WIDTH, (float)- BORDER_WIDTH, (float)(optionBox.getFullBounds().getWidth() + 2 * BORDER_WIDTH), (float)(optionBox.getFullBounds().getHeight() + 2 * BORDER_WIDTH));
optionBox.addChild(optionBoxouterRect);
optionBox.setBounds(optionBoxouterRect.getBounds());
optionBoxouterRect.moveToBack();
addChild(symbolText);
updateLine();
this.addInputEventListener(joinChangeListener);
joinCombo = new PNode();
joinCombo.addInputEventListener(new PBasicInputEventHandler() {
@Override
public void mouseExited(PInputEvent event) {
if (!joinCombo.getBounds().contains(joinCombo.globalToLocal(event.getPosition())) && canvas.getLayer().getAllNodes().contains(joinCombo)) {
canvas.getLayer().removeChild(joinCombo);
}
}
});
optionBox.addInputEventListener(new PBasicInputEventHandler() {
@Override
public void mouseExited(PInputEvent event) {
if (!optionBox.getBounds().contains(optionBox.globalToLocal(event.getPosition())) && canvas.getLayer().getAllNodes().contains(optionBox)) {
canvas.getLayer().removeChild(optionBox);
}
}
});
PText innerJoinComboItem = new PText("Inner Join");
innerJoinComboItem.addAttribute(StyleConstants.FontFamily, UIManager.getFont("List.font").getFamily());
joinCombo.addChild(innerJoinComboItem);
innerJoinComboItem.addInputEventListener(new PBasicInputEventHandler() {
@Override
public void mouseReleased(PInputEvent event) {
canvas.getLayer().removeChild(joinCombo);
if (clickedOnLeftPath) {
model.setLeftColumnOuterJoin(false);
} else {
model.setRightColumnOuterJoin(false);
}
updateLine();
}
});
PText outerJoinComboItem = new PText("Outer Join");
outerJoinComboItem.addAttribute(StyleConstants.FontFamily, UIManager.getFont("List.font").getFamily());
outerJoinComboItem.translate(0, innerJoinComboItem.getHeight() + BORDER_WIDTH);
joinCombo.addChild(outerJoinComboItem);
outerJoinComboItem.addInputEventListener(new PBasicInputEventHandler() {
@Override
public void mouseReleased(PInputEvent event) {
if (clickedOnLeftPath) {
model.setLeftColumnOuterJoin(true);
} else {
model.setRightColumnOuterJoin(true);
}
canvas.getLayer().removeChild(joinCombo);
updateLine();
}
});
PPath outerRect = PPath.createRectangle((float)- BORDER_WIDTH, (float)- BORDER_WIDTH, (float)(joinCombo.getFullBounds().getWidth() + 2 * BORDER_WIDTH), (float)(joinCombo.getFullBounds().getHeight() + 2 * BORDER_WIDTH));
joinCombo.addChild(outerRect);
joinCombo.setBounds(outerRect.getBounds());
outerRect.moveToBack();
PNotificationCenter.defaultCenter().addListener(this, "setFocusColour", PSelectionEventHandler.SELECTION_CHANGED_NOTIFICATION, null);
setFocusColour(new PNotification(null, null, null));
editorPane.setText(model.getComparator());
symbolText.syncWithDocument();
updateLine();
}
/**
* Updates the line end points and control points. The text area is also moved.
*/
private void updateLine() {
setBounds(0, 0, 0, 0);
PBounds leftBounds = this.leftNode.getGlobalFullBounds();
PBounds rightBounds = this.rightNode.getGlobalFullBounds();
PBounds leftContainerBounds = leftContainerPane.getGlobalBounds();
PBounds rightContainerBounds = rightContainerPane.getGlobalBounds();
leftPath.reset();
rightPath.reset();
double leftY = leftBounds.getY() + leftBounds.getHeight()/2;
double rightY = rightBounds.getY() + rightBounds.getHeight()/2;
double midY = Math.abs(leftY - rightY) / 2 + Math.min(leftY, rightY);
double leftX = leftContainerBounds.getX();
double rightX = rightContainerBounds.getX();
double midX;
int rightContainerFirstControlPointDirection = -1;
int leftContainerFirstControlPointDirection = 1;
if (leftX + leftContainerBounds.getWidth() < rightX) {
leftX += leftContainerBounds.getWidth();
midX = leftX + (rightX - leftX)/2;
rightContainerFirstControlPointDirection = 1;
isJoinedTablesSwapped = false;
logger.debug("Left container is to the left of the right container.");
} else if (leftX < rightContainerBounds.getWidth() + rightContainerBounds.getX()) {
leftX += leftContainerBounds.getWidth();
rightX += rightContainerBounds.getWidth();
midX = Math.max(JOIN_LINE_STICKOUT_LENGTH + leftX, JOIN_LINE_STICKOUT_LENGTH + rightX);
logger.debug("The containers are above or below eachother.");
} else {
rightX += rightContainerBounds.getWidth();
midX = leftX + (rightX - leftX)/2;
leftContainerFirstControlPointDirection = -1;
isJoinedTablesSwapped = true;
logger.debug("The right container is to the left of the left container.");
}
handleJoinedTablesSwapped ();
logger.debug("Left x position is " + leftX + " and mid x position is " + midX);
// For two Bezier curves to be connected the last point in the first
// curve must equal the first point in the second curve.
// For two Bezier curves to be continuous on the first derivative the
// connecting point must be on the line made by the second control point
// of the first curve and the first control point of the second curve.
leftPath.moveTo((float)(leftX), (float)(leftY));
Point2D leftControlPoint1 = new Point2D.Float((float)(leftX + leftContainerFirstControlPointDirection * Math.max(JOIN_LINE_STICKOUT_LENGTH, Math.abs(rightX - leftX)/6)), (float)leftY);
Point2D leftControlPoint2 = new Point2D.Float((float)midX, (float)(leftY + (rightY - leftY)/6));
leftPath.curveTo((float)leftControlPoint1.getX(), (float)leftControlPoint1.getY(), (float)leftControlPoint2.getX(), (float)leftControlPoint2.getY(), (float)midX, (float)midY);
rightPath.moveTo((float)midX, (float)midY);
Point2D rightControlPoint1 = new Point2D.Float((float)midX, (float)(leftY + (rightY - leftY)*5/6));
Point2D rightControlPoint2 = new Point2D.Float( (float)(rightX - rightContainerFirstControlPointDirection * Math.max(JOIN_LINE_STICKOUT_LENGTH, Math.abs(rightX - leftX)/6)), (float)rightY);
rightPath.curveTo((float)rightControlPoint1.getX(), (float)rightControlPoint1.getY(), (float)rightControlPoint2.getX(), (float)rightControlPoint2.getY(), (float)(rightX), (float)(rightY));
float[] dash = { DASH_WIDTH, DASH_WIDTH };
if (model.isLeftColumnOuterJoin()) {
leftPath.setStroke(new BasicStroke(1, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER, 1, dash, 0));
} else {
leftPath.setStroke(new BasicStroke());
}
if (model.isRightColumnOuterJoin()) {
rightPath.setStroke(new BasicStroke(1, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER, 1, dash, 0));
} else {
rightPath.setStroke(new BasicStroke());
}
double textMidX = midX - symbolText.getWidth()/2;
double textMidY = midY - symbolText.getHeight()/2;
symbolText.setX(textMidX);
symbolText.setY(textMidY);
textCircle.setPathToEllipse((float)(textMidX - BORDER_WIDTH),
(float)(textMidY - BORDER_WIDTH),
(float)symbolText.getWidth() + 2 * BORDER_WIDTH,
(float)symbolText.getHeight() + 2 * BORDER_WIDTH);
logger.debug("The model's comparator is \"" + model.getComparator() + "\" looking for " + SQLJoin.Comparators.EQUAL_TO.getComparator());
selectedImageNode.setX(midX - selectedImageNode.getWidth()/2);
selectedImageNode.setY(midY - selectedImageNode.getHeight()/2);
unselectedImageNode.setX(midX - unselectedImageNode.getWidth()/2);
unselectedImageNode.setY(midY - unselectedImageNode.getHeight()/2);
if (model.getComparator().equals(SQLJoin.Comparators.EQUAL_TO.getComparator())) {
textCircle.setVisible(false);
symbolText.setVisible(false);
setFocusColour(new PNotification(null, null, null));
} else {
selectedImageNode.setVisible(false);
unselectedImageNode.setVisible(false);
textCircle.setVisible(true);
symbolText.setVisible(true);
}
Rectangle2D boundUnion = textCircle.getBounds();
boundUnion = boundUnion.createUnion(leftPath.getBounds());
boundUnion = boundUnion.createUnion(rightPath.getBounds());
setBounds(boundUnion);
}
/**
* flip the symbols when joined tables are swapped
*/
public void handleJoinedTablesSwapped () {
if (isJoinedTablesSwapped != oldIsJoinedTableSwapped) {
viewCom = getOppositeSymbol(viewCom);
editorPane.setText(viewCom);
symbolText.syncWithDocument();
oldIsJoinedTableSwapped = isJoinedTablesSwapped;
}
}
public String getOppositeSymbol (String symbol) {
if ( symbol.equals(">") ) {
return "<";
} else if ( symbol.equals(">=") ) {
return "<=";
} else if ( symbol.equals("<") ) {
return ">";
} else if ( symbol.equals("<=") ) {
return ">=";
} else {
return symbol;
}
}
/**
* Returns true if the user clicked on or near the given path. Returns
* false otherwise. Clicking in the join circle will not be considered
* clicking near the line.
*/
private boolean checkClickOnPath(double mouseX, double mouseY, PPath path) {
Rectangle2D mouseClickRectangle = new Rectangle((int)mouseX - MOUSE_CLICK_BUFFER, (int)mouseY - MOUSE_CLICK_BUFFER, 2 * MOUSE_CLICK_BUFFER, 2 * MOUSE_CLICK_BUFFER);
PathIterator iter = path.getPathReference().getPathIterator(path.getTransform(), 1);
float [] linePoints = new float[2];
Point2D oldPoints;
iter.currentSegment(linePoints);
iter.next();
if (textCircle.getPathReference().contains(mouseX, mouseY)) {
return true;
}
while (!iter.isDone()) {
oldPoints = new Point2D.Float(linePoints[0], linePoints[1]);
iter.currentSegment(linePoints);
if (mouseClickRectangle.intersectsLine(oldPoints.getX(), oldPoints.getY(), linePoints[0], linePoints[1])) {
return true;
}
iter.next();
}
return false;
}
public UnmodifiableItemPNode getLeftNode() {
return leftNode;
}
public UnmodifiableItemPNode getRightNode() {
return rightNode;
}
public SQLJoin getModel() {
return model;
}
public JEditorPane getEditorPane(){
return editorPane;
}
public void disconnectJoin() {
model.removeAllListeners();
leftNode.removeJoinedLine(this);
rightNode.removeJoinedLine(this);
}
@Override
/*
* Overriding the fullPick here so that the join line only actually
* gets picked when you click on the circle or close to a line
*/
public boolean fullPick(PPickPath pickPath) {
boolean superPick = super.fullPick(pickPath);
if (superPick
&& (pickPath.getPickedNode() != this
|| checkClickOnPath(pickPath.getPickBounds().getX(), pickPath.getPickBounds().getY(), leftPath)
|| checkClickOnPath(pickPath.getPickBounds().getX(), pickPath.getPickBounds().getY(), rightPath))) {
PNode picked = pickPath.getPickedNode();
while (picked != this) {
pickPath.popTransform(picked.getTransformReference(false));
pickPath.popNode(picked);
picked = pickPath.getPickedNode();
}
return true;
}
return false;
}
public void setFocusColour(PNotification notification) {
boolean hasFocus = queryPen.getMultipleSelectEventHandler().getSelection().contains(this);
if (hasFocus) {
leftPath.setStrokePaint(QueryPen.SELECTED_CONTAINER_COLOUR);
rightPath.setStrokePaint(QueryPen.SELECTED_CONTAINER_COLOUR);
textCircle.setStrokePaint(QueryPen.SELECTED_CONTAINER_COLOUR);
unselectedImageNode.setVisible(false);
selectedImageNode.setVisible(true);
} else {
leftPath.setStrokePaint(QueryPen.UNSELECTED_CONTAINER_COLOUR);
rightPath.setStrokePaint(QueryPen.UNSELECTED_CONTAINER_COLOUR);
textCircle.setStrokePaint(QueryPen.UNSELECTED_CONTAINER_COLOUR);
unselectedImageNode.setVisible(true);
selectedImageNode.setVisible(false);
}
}
public void cleanup() {
model.removeJoinChangeListener(joinListener);
PNotificationCenter.defaultCenter().removeListener(this);
}
}