/* Alloy Analyzer 4 -- Copyright (c) 2006-2009, Felix Chang * * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files * (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF * OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package edu.mit.csail.sdg.alloy4graph; import static java.awt.event.InputEvent.BUTTON1_MASK; import static java.awt.event.InputEvent.BUTTON3_MASK; import static java.awt.event.InputEvent.CTRL_MASK; import static java.awt.Color.WHITE; import static java.awt.Color.BLACK; import java.awt.Color; import java.awt.Component; import java.awt.Container; import java.awt.Dimension; import java.awt.Graphics; import java.awt.Graphics2D; import java.awt.Rectangle; import java.awt.RenderingHints; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.awt.event.MouseMotionAdapter; import java.awt.geom.AffineTransform; import java.awt.image.BufferedImage; import java.io.File; import java.io.IOException; import javax.swing.JLabel; import javax.swing.JMenuItem; import javax.swing.JPanel; import javax.swing.JPopupMenu; import javax.swing.JRadioButton; import javax.swing.JTextField; import javax.swing.JViewport; import javax.swing.border.EmptyBorder; import javax.swing.event.DocumentEvent; import javax.swing.event.DocumentListener; import edu.mit.csail.sdg.alloy4.OurDialog; import edu.mit.csail.sdg.alloy4.OurPDFWriter; import edu.mit.csail.sdg.alloy4.OurPNGWriter; import edu.mit.csail.sdg.alloy4.OurUtil; import edu.mit.csail.sdg.alloy4.Util; /** This class displays the graph. * * <p><b>Thread Safety:</b> Can be called only by the AWT event thread. */ public final strictfp class GraphViewer extends JPanel { /** This ensures the class can be serialized reliably. */ private static final long serialVersionUID = 0; /** The graph that we are displaying. */ private final Graph graph; /** The current amount of zoom. */ private double scale = 1d; /** The currently hovered GraphNode or GraphEdge or group, or null if there is none. */ private Object highlight = null; /** The currently selected GraphNode or GraphEdge or group, or null if there is none. */ private Object selected = null; /** The button that initialized the drag-and-drop; this value is undefined when we're not currently doing drag-and-drop. */ private int dragButton = 0; /** The right-click context menu associated with this JPanel. */ public final JPopupMenu pop = new JPopupMenu(); /** Locates the node or edge at the given (X,Y) location. */ private Object alloyFind(int mouseX, int mouseY) { return graph.find(scale, mouseX, mouseY); } /** Returns the annotation for the node or edge at location x,y (or null if none) */ public Object alloyGetAnnotationAtXY(int mouseX, int mouseY) { Object obj = alloyFind(mouseX, mouseY); if (obj instanceof GraphNode) return ((GraphNode)obj).uuid; if (obj instanceof GraphEdge) return ((GraphEdge)obj).uuid; return null; } /** Returns the annotation for the currently selected node/edge (or null if none) */ public Object alloyGetSelectedAnnotation() { if (selected instanceof GraphNode) return ((GraphNode)selected).uuid; if (selected instanceof GraphEdge) return ((GraphEdge)selected).uuid; return null; } /** Returns the annotation for the currently highlighted node/edge (or null if none) */ public Object alloyGetHighlightedAnnotation() { if (highlight instanceof GraphNode) return ((GraphNode)highlight).uuid; if (highlight instanceof GraphEdge) return ((GraphEdge)highlight).uuid; return null; } /** Stores the mouse positions needed to calculate drag-and-drop. */ private int oldMouseX=0, oldMouseY=0, oldX=0, oldY=0; /** Repaint this component. */ public void alloyRepaint() { Container c=getParent(); while(c!=null) { if (c instanceof JViewport) break; else c=c.getParent(); } setSize((int)(graph.getTotalWidth()*scale), (int)(graph.getTotalHeight()*scale)); if (c!=null) { c.invalidate(); c.repaint(); c.validate(); } else { invalidate(); repaint(); validate(); } } /** Construct a GraphViewer that displays the given graph. */ public GraphViewer(final Graph graph) { OurUtil.make(this, BLACK, WHITE, new EmptyBorder(0,0,0,0)); setBorder(null); this.scale = graph.defaultScale; this.graph = graph; graph.layout(); final JMenuItem zoomIn = new JMenuItem("Zoom In"); final JMenuItem zoomOut = new JMenuItem("Zoom Out"); final JMenuItem zoomToFit = new JMenuItem("Zoom to Fit"); final JMenuItem print = new JMenuItem("Export to PNG or PDF"); pop.add(zoomIn); pop.add(zoomOut); pop.add(zoomToFit); pop.add(print); ActionListener act = new ActionListener() { public void actionPerformed(ActionEvent e) { Container c=getParent(); while(c!=null) { if (c instanceof JViewport) break; else c=c.getParent(); } if (e.getSource() == print) alloySaveAs(); if (e.getSource() == zoomIn) { scale=scale*1.33d; if (!(scale<500d)) scale=500d; } if (e.getSource() == zoomOut) { scale=scale/1.33d; if (!(scale>0.1d)) scale=0.1d; } if (e.getSource() == zoomToFit) { if (c==null) return; int w=c.getWidth()-15, h=c.getHeight()-15; // 15 gives a comfortable round-off margin if (w<=0 || h<=0) return; double scale1 = ((double)w)/graph.getTotalWidth(), scale2 = ((double)h)/graph.getTotalHeight(); if (scale1<scale2) scale=scale1; else scale=scale2; } alloyRepaint(); } }; zoomIn.addActionListener(act); zoomOut.addActionListener(act); zoomToFit.addActionListener(act); print.addActionListener(act); addMouseMotionListener(new MouseMotionAdapter() { @Override public void mouseMoved(MouseEvent ev) { if (pop.isVisible()) return; Object obj = alloyFind(ev.getX(), ev.getY()); if (highlight!=obj) { highlight=obj; alloyRepaint(); } } @Override public void mouseDragged(MouseEvent ev) { if (selected instanceof GraphNode && dragButton==1) { int newX=(int)(oldX+(ev.getX()-oldMouseX)/scale); int newY=(int)(oldY+(ev.getY()-oldMouseY)/scale); GraphNode n=(GraphNode)selected; if (n.x()!=newX || n.y()!=newY) { n.tweak(newX,newY); alloyRepaint(); scrollRectToVisible(new Rectangle( (int)((newX-graph.getLeft())*scale)-n.getWidth()/2-5, (int)((newY-graph.getTop())*scale)-n.getHeight()/2-5, n.getWidth()+n.getReserved()+10, n.getHeight()+10 )); } } } }); addMouseListener(new MouseAdapter() { @Override public void mouseReleased(MouseEvent ev) { Object obj = alloyFind(ev.getX(), ev.getY()); graph.recalcBound(true); selected=null; highlight=obj; alloyRepaint(); } @Override public void mousePressed(MouseEvent ev) { dragButton=0; int mod = ev.getModifiers(); if ((mod & BUTTON3_MASK)!=0) { selected=alloyFind(ev.getX(), ev.getY()); highlight=null; alloyRepaint(); pop.show(GraphViewer.this, ev.getX(), ev.getY()); } else if ((mod & BUTTON1_MASK)!=0 && (mod & CTRL_MASK)!=0) { // This lets Ctrl+LeftClick bring up the popup menu, just like RightClick, // since many Mac mouses do not have a right button. selected=alloyFind(ev.getX(), ev.getY()); highlight=null; alloyRepaint(); pop.show(GraphViewer.this, ev.getX(), ev.getY()); } else if ((mod & BUTTON1_MASK)!=0) { dragButton=1; selected=alloyFind(oldMouseX=ev.getX(), oldMouseY=ev.getY()); highlight=null; alloyRepaint(); if (selected instanceof GraphNode) { oldX=((GraphNode)selected).x(); oldY=((GraphNode)selected).y(); } } } @Override public void mouseExited(MouseEvent ev) { if (highlight!=null) { highlight=null; alloyRepaint(); } } }); } /** This color is used as the background for a JTextField that contains bad data. * <p> Note: we intentionally choose to make it an instance field rather than a static field, * since we want to make sure we only instantiate it from the AWT Event Dispatching thread. */ private final Color badColor = new Color(255,200,200); /** This synchronized field stores the most recent DPI value. */ private static volatile double oldDPI=72; /** True if we are currently in the middle of a DocumentListener already. */ private boolean recursive=false; /** This updates the three input boxes and the three accompanying text labels, then return the width in pixels. */ private int alloyRefresh (int who, double ratio, JTextField w1, JLabel w2, JTextField h1, JLabel h2, JTextField d1, JLabel d2, JLabel msg) { if (recursive) return 0; try { recursive=true; w1.setBackground(WHITE); h1.setBackground(WHITE); d1.setBackground(WHITE); boolean bad=false; double w; try { w=Double.parseDouble(w1.getText()); } catch(NumberFormatException ex) { w=0; } double h; try { h=Double.parseDouble(h1.getText()); } catch(NumberFormatException ex) { h=0; } double d; try { d=Double.parseDouble(d1.getText()); } catch(NumberFormatException ex) { d=0; } if (who==1) { h=((int)(w*100/ratio))/100D; h1.setText(""+h); } // Maintains aspect ratio if (who==2) { w=((int)(h*100*ratio))/100D; w1.setText(""+w); } // Maintains aspect ratio if (!(d>=0.01) || !(d<=10000)) { bad=true; d1.setBackground(badColor); msg.setText("DPI must be between 0.01 and 10000"); } if (!(h>=0.01) || !(h<=10000)) { bad=true; h1.setBackground(badColor); msg.setText("Height must be between 0.01 and 10000"); if (who==1) h1.setText(""); } if (!(w>=0.01) || !(w<=10000)) { bad=true; w1.setBackground(badColor); msg.setText("Width must be between 0.01 and 10000"); if (who==2) w1.setText(""); } if (bad) { w2.setText(" inches"); h2.setText(" inches"); return 0; } else msg.setText(" "); w2.setText(" inches ("+(int)(w*d)+" pixels)"); h2.setText(" inches ("+(int)(h*d)+" pixels)"); return (int)(w*d); } finally { recursive=false; } } /** Export the current drawing as a PNG or PDF file by asking the user for the filename and the image resolution. */ public void alloySaveAs() { // Figure out the initial width, height, and DPI that we might want to suggest to the user final double ratio=((double)(graph.getTotalWidth()))/graph.getTotalHeight(); double dpi, iw=8.5D, ih=((int)(iw*100/ratio))/100D; // First set the width to be 8.5inch and compute height accordingly if (ih>11D) { ih=11D; iw=((int)(ih*100*ratio))/100D; } // If too tall, then set height=11inch, and compute width accordingly synchronized(GraphViewer.class) { dpi=oldDPI; } // Prepare the dialog box final JLabel msg = OurUtil.label(" ", Color.RED); final JLabel w = OurUtil.label("Width: "+((int)(graph.getTotalWidth()*scale))+" pixels"); final JLabel h = OurUtil.label("Height: "+((int)(graph.getTotalHeight()*scale))+" pixels"); final JTextField w1 = new JTextField(""+iw); final JLabel w0 = OurUtil.label("Width: "), w2 = OurUtil.label(""); final JTextField h1 = new JTextField(""+ih); final JLabel h0 = OurUtil.label("Height: "), h2 = OurUtil.label(""); final JTextField d1 = new JTextField(""+(int)dpi); final JLabel d0 = OurUtil.label("Resolution: "), d2 = OurUtil.label(" dots per inch"); final JTextField dp1 = new JTextField(""+(int)dpi);final JLabel dp0 = OurUtil.label("Resolution: "), dp2 = OurUtil.label(" dots per inch"); alloyRefresh(0, ratio, w1, w2, h1, h2, d1, d2, msg); Dimension dim = new Dimension(100,20); w1.setMaximumSize(dim); w1.setPreferredSize(dim); w1.setEnabled(false); h1.setMaximumSize(dim); h1.setPreferredSize(dim); h1.setEnabled(false); d1.setMaximumSize(dim); d1.setPreferredSize(dim); d1.setEnabled(false); dp1.setMaximumSize(dim); dp1.setPreferredSize(dim); dp1.setEnabled(false); w1.getDocument().addDocumentListener(new DocumentListener() { public void changedUpdate(DocumentEvent e) { alloyRefresh(1,ratio,w1,w2,h1,h2,d1,d2,msg); } public void insertUpdate(DocumentEvent e) { changedUpdate(null); } public void removeUpdate(DocumentEvent e) { changedUpdate(null); } }); h1.getDocument().addDocumentListener(new DocumentListener() { public void changedUpdate(DocumentEvent e) { alloyRefresh(2,ratio,w1,w2,h1,h2,d1,d2,msg); } public void insertUpdate(DocumentEvent e) { changedUpdate(null); } public void removeUpdate(DocumentEvent e) { changedUpdate(null); } }); d1.getDocument().addDocumentListener(new DocumentListener() { public void changedUpdate(DocumentEvent e) { alloyRefresh(3,ratio,w1,w2,h1,h2,d1,d2,msg); } public void insertUpdate(DocumentEvent e) { changedUpdate(null); } public void removeUpdate(DocumentEvent e) { changedUpdate(null); } }); final JRadioButton b1 = new JRadioButton("As a PNG with the window's current magnification:", true); final JRadioButton b2 = new JRadioButton("As a PNG with a specific width, height, and resolution:", false); final JRadioButton b3 = new JRadioButton("As a PDF with the given resolution:", false); b1.addActionListener(new ActionListener() { public void actionPerformed(ActionEvent e) { b2.setSelected(false); b3.setSelected(false); if (!b1.isSelected()) b1.setSelected(true); w1.setEnabled(false); h1.setEnabled(false); d1.setEnabled(false); dp1.setEnabled(false); msg.setText(" "); w1.setBackground(WHITE); h1.setBackground(WHITE); d1.setBackground(WHITE); } }); b2.addActionListener(new ActionListener() { public void actionPerformed(ActionEvent e) { b1.setSelected(false); b3.setSelected(false); if (!b2.isSelected()) b2.setSelected(true); w1.setEnabled(true); h1.setEnabled(true); d1.setEnabled(true); dp1.setEnabled(false); msg.setText(" "); alloyRefresh(1,ratio,w1,w2,h1,h2,d1,d2,msg); } }); b3.addActionListener(new ActionListener() { public void actionPerformed(ActionEvent e) { b1.setSelected(false); b2.setSelected(false); if (!b3.isSelected()) b3.setSelected(true); w1.setEnabled(false); h1.setEnabled(false); d1.setEnabled(false); dp1.setEnabled(true); msg.setText(" "); w1.setBackground(WHITE); h1.setBackground(WHITE); d1.setBackground(WHITE); } }); // Ask whether the user wants to change the width, height, and DPI double myScale; while(true) { if (!OurDialog.getInput("Export as PNG or PDF", new Object[]{ b1, OurUtil.makeH(20, w, null), OurUtil.makeH(20, h, null), " ", b2, OurUtil.makeH(20, w0, w1, w2, null), OurUtil.makeH(20, h0, h1, h2, null), OurUtil.makeH(20, d0, d1, d2, null), OurUtil.makeH(20, msg, null), b3, OurUtil.makeH(20, dp0, dp1, dp2, null) })) return; // Let's validate the values if (b2.isSelected()) { int widthInPixel=alloyRefresh(3,ratio,w1,w2,h1,h2,d1,d2,msg); String err = msg.getText().trim(); if (err.length()>0) continue; dpi=Double.parseDouble(d1.getText()); myScale=((double)widthInPixel)/graph.getTotalWidth(); int heightInPixel=(int)(graph.getTotalHeight()*myScale); if (widthInPixel>4000 || heightInPixel>4000) if (!OurDialog.yesno("The image dimension ("+widthInPixel+"x"+heightInPixel+") is very large. Are you sure?")) continue; } else if (b3.isSelected()) { try { dpi=Double.parseDouble(dp1.getText()); } catch(NumberFormatException ex) { dpi=(-1); } if (dpi<50 || dpi>3000) { OurDialog.alert("The DPI must be between 50 and 3000."); continue; } myScale=0; // This field is unused for PDF generation } else { dpi=72; myScale=scale; } break; } // Ask the user for a filename File filename; if (b3.isSelected()) filename = OurDialog.askFile(false, null, ".pdf", "PDF file"); else filename = OurDialog.askFile(false, null, ".png", "PNG file"); if (filename==null) return; if (filename.exists() && !OurDialog.askOverwrite(filename.getAbsolutePath())) return; // Attempt to write the PNG or PDF file try { System.gc(); // Try to avoid possible premature out-of-memory exceptions if (b3.isSelected()) alloySaveAsPDF(filename.getAbsolutePath(), (int)dpi); else alloySaveAsPNG(filename.getAbsolutePath(), myScale, dpi, dpi); synchronized(GraphViewer.class) { oldDPI=dpi; } Util.setCurrentDirectory(filename.getParentFile()); } catch(Throwable ex) { OurDialog.alert("An error has occured in writing the output file:\n" + ex); } } /** Export the current drawing as a PDF file with the given image resolution. */ public void alloySaveAsPDF(String filename, int dpi) throws IOException { try { double xwidth = dpi*8L+(dpi/2L); // Width is up to 8.5 inch double xheight = dpi*11L; // Height is up to 11 inch double scale1 = (xwidth-dpi) / graph.getTotalWidth(); // We leave 0.5 inch on the left and right double scale2 = (xheight-dpi) / graph.getTotalHeight(); // We leave 0.5 inch on the left and right if (scale1<scale2) scale2=scale1; // Choose the scale such that the image does not exceed the page in either direction OurPDFWriter x = new OurPDFWriter(filename, dpi, scale2); graph.draw(new Artist(x), scale2, null, false); x.close(); } catch(Throwable ex) { if (ex instanceof IOException) throw (IOException)ex; throw new IOException("Failure writing the PDF file to " + filename + " (" + ex + ")"); } } /** Export the current drawing as a PNG file with the given file name and image resolution. */ public void alloySaveAsPNG(String filename, double scale, double dpiX, double dpiY) throws IOException { try { int width = (int) (graph.getTotalWidth()*scale); if (width<10) width=10; int height = (int) (graph.getTotalHeight()*scale); if (height<10) height=10; BufferedImage bf = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); Graphics2D gr = (Graphics2D) (bf.getGraphics()); gr.setColor(WHITE); gr.fillRect(0, 0, width, height); gr.setColor(BLACK); gr.scale(scale,scale); gr.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); graph.draw(new Artist(gr), scale, null, false); OurPNGWriter.writePNG(bf, filename, dpiX, dpiY); } catch(Throwable ex) { if (ex instanceof IOException) throw (IOException)ex; throw new IOException("Failure writing the PNG file to " + filename + " (" + ex + ")"); } } /** Show the popup menu at location (x,y) */ public void alloyPopup(Component c, int x, int y) { pop.show(c,x,y); } /** Returns a DOT representation of the current graph. */ @Override public String toString() { return graph.toString(); } /** Returns the preferred size of this component. */ @Override public Dimension getPreferredSize() { return new Dimension((int)(graph.getTotalWidth()*scale), (int)(graph.getTotalHeight()*scale)); } /** This method is called by Swing to draw this component. */ @Override public void paintComponent(final Graphics gr) { super.paintComponent(gr); Graphics2D g2 = (Graphics2D)gr; AffineTransform oldAF = (AffineTransform) (g2.getTransform().clone()); g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g2.scale(scale, scale); Object sel=(selected!=null ? selected : highlight); GraphNode c=null; if (sel instanceof GraphNode && ((GraphNode)sel).shape()==null) { c = (GraphNode)sel; sel = c.ins.get(0); } graph.draw(new Artist(g2), scale, sel, true); if (c!=null) { gr.setColor(((GraphEdge)sel).color()); gr.fillArc(c.x()-5-graph.getLeft(), c.y()-5-graph.getTop(), 10, 10, 0, 360); } g2.setTransform(oldAF); } }