/* 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); } }