ded.ui.DiagramController.java Source code

Java tutorial

Introduction

Here is the source code for ded.ui.DiagramController.java

Source

// DiagramController.java
// See toplevel license.txt for copyright and license terms.

package ded.ui;

import java.awt.AWTEvent;
import java.awt.Color;
import java.awt.Component;
import java.awt.Cursor;
import java.awt.FontMetrics;
import java.awt.Graphics;
import java.awt.GraphicsConfiguration;
import java.awt.GraphicsDevice;
import java.awt.GraphicsEnvironment;
import java.awt.Image;
import java.awt.KeyboardFocusManager;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.Toolkit;
import java.awt.datatransfer.Clipboard;
import java.awt.datatransfer.DataFlavor;
import java.awt.datatransfer.StringSelection;
import java.awt.datatransfer.Transferable;
import java.awt.event.ComponentEvent;
import java.awt.event.ComponentListener;
import java.awt.event.FocusEvent;
import java.awt.event.FocusListener;
import java.awt.event.KeyEvent;
import java.awt.event.KeyListener;
import java.awt.event.MouseEvent;
import java.awt.event.MouseListener;
import java.awt.event.MouseMotionListener;
import java.awt.font.LineMetrics;
import java.awt.image.BufferedImage;
import java.awt.image.ColorModel;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.StringReader;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.Set;

import javax.imageio.ImageIO;
import javax.swing.JFileChooser;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.SwingUtilities;
import javax.swing.filechooser.FileNameExtensionFilter;

import org.json.JSONException;

import util.IdentityHashSet;
import util.ImageFileUtil;
import util.Util;
import util.awt.GeomUtil;
import util.swing.SwingUtil;

import ded.Ded;
import ded.model.ArrowStyle;
import ded.model.Diagram;
import ded.model.Entity;
import ded.model.EntityShape;
import ded.model.Inheritance;
import ded.model.Relation;
import ded.model.RelationEndpoint;

/** Widget to display and edit a diagram. */
public class DiagramController extends JPanel
        implements MouseListener, MouseMotionListener, KeyListener, ComponentListener, FocusListener {
    // ------------- constants ---------------
    private static final long serialVersionUID = 1266678840598864303L;

    /** Pixels from left/top edge to draw the file name label. */
    public static final int fileNameLabelMargin = 2;

    private static final String helpMessage = "H or F1 - This message\n" + "Q - Quit\n" + "S - Select mode\n"
            + "C - Create entity mode\n" + "A - Create relation (\"arrow\") mode\n"
            + "I - Create inheritance mode\n" + "Enter or Double click - Edit selected thing\n"
            + "Insert - Insert relation control point\n" + "Delete - Delete selected thing\n"
            + "Ctrl+C, Ctrl-V - Copy/paste, including across windows\n"
            + "Ctrl+S - Save to file and export to PNG (fname+\".png\")\n"
            + "Ctrl+O - Load from file (can import ER files)\n" + "Left click - select\n"
            + "Ctrl+Left click - multiselect\n" + "Left click+drag - multiselect rectangle\n"
            + "Right click - properties\n" + "\n" + "When entity selected, F/B to move to front/back.\n"
            + "When relation selected, H/V/D to change routing,\n" + "and O to toggle owned/shared.\n"
            + "When inheritance selected, O to change open/closed.\n"
            + "When dragging, hold Shift to turn off 5-pixel snap.\n" + "\n"
            + "See menu bar for commands without keybindings.";

    /** The existence and value of this field tells (via reflection)
      * Abbot, a GUI test tool, that it should record low-level mouse
      * events rather than converting them into "click" or "drag"
      * events. */
    public static String abbotRecorderClassName = "NoClickComponent";

    /** When true, turn on some extra diagnostics related to debugging
      * a problem with Abbot where it interferes with normal focus. */
    public static final boolean debugFocus = false;

    // ------------- static data ---------------
    /** Granularity of drag/move snap action. */
    public static final int SNAP_DIST = 5;

    // ------------- private types ---------------
    /** Primary "mode" of the editing interface, indicating what happens
      * when the left mouse button is clicked or released. */
    public static enum Mode {
        DCM_SELECT // click to select/move/resize
        ("Select"), DCM_CREATE_ENTITY // click to create an entity
        ("Create entity"), DCM_CREATE_RELATION // click to create a relation
        ("Create relation"), DCM_CREATE_INHERITANCE // click to create an inheritance relation
        ("Create inheritance"), DCM_DRAGGING // currently drag-moving something
        ("Dragging"), DCM_RECT_LASSO // currently drag-lasso selecting
        ("Rectangle lasso selecting");

        /** User-visible description of the mode. */
        public final String description;

        private Mode(String d) {
            this.description = d;
        }
    }

    // ------------- instance data ---------------
    /** Parent diagram editor window. */
    private Ded dedWindow;

    /** The diagram we are editing. */
    public Diagram diagram;

    /** Set of controllers for elements of the diagram.  For the moment, the order
      * is supposed to be the same as the corresponding 'diagram' model elements,
      * but I'm not sure how I'm going to maintain that invariant or if it is
      * really what I want. */
    private ArrayList<Controller> controllers;

    /** Current primary editing mode. */
    private Mode mode;

    /** If DCM_RECT_LASSO, the point where the mouse button was originally pressed. */
    private Point lassoStart;

    /** If DCM_RECT_LASSO, the current mouse position. */
    private Point lassoEnd;

    /** If DCM_RECT_LASSO, the set of originally-selected controllers,
      * which is to be included in whatever is contained by the lasso. */
    private IdentityHashSet<Controller> lassoOriginalSelected = new IdentityHashSet<Controller>();

    /** If CFM_DRAGGING, this is the controller being moved. */
    private Controller dragging;

    /** If CFM_DRAGGING, this is the vector from the original mouse click point
      * to the Controller's original getLoc(). */
    private Point dragOffset;

    /** Most recently used file name, or "" if there is none. */
    private String fileName;

    /** Most recently used directory for loading/saving files. */
    private File currentFileChooserDirectory;

    /** When true, the in-memory Diagram has been modified since the
      * last time it was saved. */
    private boolean dirty;

    /** When true, the in-memory Diagram was loaded from a file that
      * was in the ER format.  This matters because we cannot *save*
      * the file in that format. */
    private boolean importedFile;

    /** Map from image file name to cached image.  A name can be
      * mapped to null, meaning we failed to load the image. */
    private HashMap<String, Image> imageCache;

    /** Accumulated log messages. */
    private StringBuilder logMessages;

    /** When not 0, we use a "triple buffer" render technique to
      * avoid problems on Apple HiDPI/Retina displays.  Mode -1
      * uses a "compatible" image.  Other values are treated as
      * "imageType" arguments to BufferedImage; for example, 1
      * is TYPE_INT_RGB, 2 is TYPE_INT_ARGB, etc. */
    private int tripleBufferMode = 0;

    /** When true, we render frames as fast as possible and measure
      * the resulting frames per second. */
    private boolean fpsMeasurementMode = false;

    /** Number of frames rendered since entering FPS mode. */
    private int fpsFrameCount = 0;

    /** System.currentTimeMillis() when we entered FPS mode. */
    private long fpsStartMillis = 0;

    /** Last FPS measurement. */
    private String fpsMeasurement = null;

    /** Number of FPS samples reported.  This is useful because
      * the effects of the JIT mean the number naturally climbs
      * over time, so I need to know about how long I have been
      * running FPS measurement so I can pick a consistent point
      * to stop and consider the measurement final. */
    private int fpsSampleCount = 0;

    // ------------- public methods ---------------
    public DiagramController(Ded dedWindow) {
        this.setBackground(Color.WHITE);

        this.dedWindow = dedWindow;
        this.diagram = new Diagram();
        this.controllers = new ArrayList<Controller>();
        this.mode = Mode.DCM_SELECT;
        this.fileName = "";
        this.currentFileChooserDirectory = Util.getWorkingDirectoryFile();
        this.dirty = false;
        this.importedFile = false;
        this.imageCache = new HashMap<String, Image>();

        this.logMessages = new StringBuilder();
        this.log("Diagram Editor started at " + (new Date()));

        String tbm = System.getenv("DED_TRIPLE_BUFFER");
        if (tbm != null) {
            try {
                this.tripleBufferMode = Integer.valueOf(tbm);
            } catch (NumberFormatException e) {
                this.log("invalid DED_TRIPLE_BUFFER value \"" + tbm + "\": " + Util.getExceptionMessage(e));
            }
        }
        this.log("DED_TRIPLE_BUFFER: " + this.tripleBufferMode);

        this.addMouseListener(this);
        this.addMouseMotionListener(this);
        this.addKeyListener(this);
        this.addComponentListener(this);
        this.addFocusListener(this);

        this.setFocusable(true);

        // I want to see Tab and Shift Tab keys in my KeyListener.
        this.setFocusTraversalKeysEnabled(false);
    }

    public Diagram getDiagram() {
        return this.diagram;
    }

    @Override
    public void paint(Graphics g) {
        // Swing JPanel is double buffered already, but that is not
        // sufficient to avoid rendering bugs on Apple computers
        // with HiDPI/Retina displays.  This is an attempt at a
        // hack that might circumvent it, effectively triple-buffering
        // the rendering step.
        if (this.tripleBufferMode != 0) {
            // The idea here is if I create an in-memory image with no
            // initial association with the display, whatever hacks Apple
            // has added should not kick in, and I get unscaled pixel
            // rendering.
            BufferedImage bi;

            if (this.tripleBufferMode == -1) {
                // This is not right because we might be drawing on a
                // different screen than the "default" screen.  Also, I
                // am worried that a "compatible" image might be one
                // subject to the scaling effects I'm trying to avoid.
                GraphicsDevice gd = GraphicsEnvironment.getLocalGraphicsEnvironment().getDefaultScreenDevice();
                GraphicsConfiguration gc = gd.getDefaultConfiguration();
                bi = gc.createCompatibleImage(this.getWidth(), this.getHeight());
            } else {
                // This is not ideal because the color representation
                // for this hidden image may not match that of the display,
                // necessitating a conversion during 'drawImage'.
                try {
                    bi = new BufferedImage(this.getWidth(), this.getHeight(), this.tripleBufferMode);
                } catch (IllegalArgumentException e) {
                    // This would happen if 'tripleBufferMode' were invalid.
                    if (this.tripleBufferMode == BufferedImage.TYPE_INT_ARGB) {
                        // I don't know how this could happen.  Re-throw.
                        this.log("creating a BufferedImage with TYPE_INT_ARGB failed: "
                                + Util.getExceptionMessage(e));
                        this.log("re-throwing exception...");
                        throw e;
                    } else {
                        // Change it to something known to be valid and try again.
                        this.log("creating a BufferedImage with imageType " + this.tripleBufferMode + " failed: "
                                + Util.getExceptionMessage(e));
                        this.log("switching type to TYPE_INT_ARGB and re-trying...");
                        this.tripleBufferMode = BufferedImage.TYPE_INT_ARGB;
                        this.paint(g);
                        return;
                    }
                }
            }
            Graphics g2 = bi.createGraphics();
            this.innerPaint(g2);
            g2.dispose();

            g.drawImage(bi, 0, 0, null /*imageObserver*/);
        } else {
            this.innerPaint(g);
        }

        if (this.fpsMeasurementMode) {
            // Immediately trigger another paint cycle.
            this.repaint();
        }
    }

    /** The core of the paint routine, after we decide whether to interpose
      * another buffer. */
    private void innerPaint(Graphics g) {
        super.paint(g);

        // I do not know the proper way to get a font set automatically
        // in a Graphics object.  Calling JComponent.setFont has gotten
        // me nowhere.  Setting it myself when I first get control
        // seems to work; but note that I have to do this *after*
        // calling super.paint().
        g.setFont(this.dedWindow.diagramFont);

        // Filename label.
        if (this.diagram.drawFileName && !this.fileName.isEmpty()) {
            String name = new File(this.fileName).getName();
            FontMetrics fm = g.getFontMetrics();
            LineMetrics lm = fm.getLineMetrics(name, g);
            int x = fileNameLabelMargin;
            int y = fileNameLabelMargin + (int) lm.getAscent();
            g.drawString(name, x, y);
            y += (int) lm.getUnderlineOffset() + 1 /*...*/;
            g.drawLine(x, y, x + fm.stringWidth(name), y);
        }

        // Controllers.
        for (Controller c : this.controllers) {
            if (c.isSelected()) {
                c.paintSelectionBackground(g);
            }
            c.paint(g);
        }

        // Lasso rectangle.
        if (this.mode == Mode.DCM_RECT_LASSO) {
            Rectangle r = this.getLassoRect();
            g.drawRect(r.x, r.y, r.width, r.height);
        }

        // Current focused Component.
        if (debugFocus) {
            KeyboardFocusManager kfm = KeyboardFocusManager.getCurrentKeyboardFocusManager();
            Component fo = kfm.getFocusOwner();
            g.drawString("Focus: " + fo, 3, this.getHeight() - 22);
        }

        // Mode label.
        if (this.mode != Mode.DCM_SELECT) {
            g.drawString("Mode: " + this.mode.description, 3, this.getHeight() - 4);
        } else if (this.fpsMeasurementMode) {
            this.fpsFrameCount++;
            long current = System.currentTimeMillis();
            long millis = current - this.fpsStartMillis;
            if (millis > 1000) {
                // Update the FPS measurement with the results for this
                // interval.
                this.fpsSampleCount++;
                this.fpsMeasurement = "FPS: " + this.fpsFrameCount + " (millis=" + millis + ", samples="
                        + this.fpsSampleCount + ")";

                // Reset the counters.
                this.fpsStartMillis = current;
                this.fpsFrameCount = 0;
            }
            g.drawString(this.fpsMeasurement + " (Ctrl+G to stop)", 3, this.getHeight() - 4);
        }
    }

    /** Return the set of currently selected controllers as a freshly
      * created set object. */
    protected HashSet<Controller> getSelectionSet() {
        HashSet<Controller> ret = new HashSet<Controller>();

        for (Controller c : this.controllers) {
            if (c.isSelected()) {
                ret.add(c);
            }
        }

        return ret;
    }

    /** Set the selection state of all of the controllers in 'set' to 'state'. */
    protected void setMultipleSelected(Set<Controller> set, SelectionState state) {
        for (Controller c : set) {
            c.setSelected(state);
        }
    }

    /** Deselect all controllers and return the number that were previously selected. */
    public int deselectAll() {
        // Get selection set first, so we only change state after iterating.
        HashSet<Controller> toDeselect = getSelectionSet();

        // Unselect them.
        setMultipleSelected(toDeselect, SelectionState.SS_UNSELECTED);

        return toDeselect.size();
    }

    /** Return the top-most Controller that contains 'point' and satisfies 'filter'
      * (if it is not null), or null if none does. */
    private Controller hitTest(Point point, ControllerFilter filter) {
        // Go backwards for top-down order.
        for (int i = this.controllers.size() - 1; i >= 0; i--) {
            Controller c = this.controllers.get(i);

            if (filter != null && filter.satisfies(c) == false) {
                continue;
            }

            if (c.boundsContains(point)) {
                return c;
            }
        }
        return null;
    }

    /** Hit test restricted to Entities. */
    private EntityController hitTestEntity(Point pt) {
        return (EntityController) hitTest(pt, new ControllerFilter() {
            public boolean satisfies(Controller c) {
                return c instanceof EntityController;
            }
        });
    }

    /** Hit test restricted to Inheritances. */
    private InheritanceController hitTestInheritance(Point pt) {
        return (InheritanceController) hitTest(pt, new ControllerFilter() {
            public boolean satisfies(Controller c) {
                return c instanceof InheritanceController;
            }
        });
    }

    /** This method is passed some of the input events.  I'm using it
      * as a convenient instrumentation point while experimenting with
      * and fixing bugs in Abbot.  In production usage, it should do
      * nothing. */
    private void eventReceived(AWTEvent e) {
        //System.out.println(e.toString());
    }

    @Override
    public void mousePressed(MouseEvent e) {
        this.eventReceived(e);

        switch (this.mode) {
        case DCM_SELECT: {
            // Clicked a controller?
            Controller c = this.hitTest(e.getPoint(), null);
            if (c == null) {
                // Missed controls, start a lasso selection
                // if left button.
                if (SwingUtilities.isLeftMouseButton(e)) {
                    this.lassoOriginalSelected.clear();
                    if (SwingUtil.controlPressed(e)) {
                        // Control is pressed.  Keep current selections.
                        this.lassoOriginalSelected.addAll(this.getAllSelected());
                    } else {
                        if (this.deselectAll() > 0) {
                            this.repaint();
                        }
                    }

                    // Enter lasso mode.
                    setMode(Mode.DCM_RECT_LASSO);
                    this.lassoStart = this.lassoEnd = e.getPoint();
                }
            } else {
                c.mousePressed(e);
            }
            break;
        }

        case DCM_CREATE_RELATION: {
            // Make a Relation that starts and ends at the current location.
            RelationEndpoint start = this.getRelationEndpoint(e.getPoint());
            RelationEndpoint end = new RelationEndpoint(start);
            end.arrowStyle = ArrowStyle.AS_FILLED_TRIANGLE;
            Relation r = new Relation(start, end);
            this.diagram.relations.add(r);
            this.setDirty();

            // Build a controller and select it.
            RelationController rc = this.buildRelationController(r);
            this.selectOnly(rc);

            // Drag the end point while the mouse button is held.
            this.beginDragging(rc.getEndHandle(), e.getPoint());

            this.repaint();
            break;
        }

        case DCM_CREATE_ENTITY: {
            EntityController.createEntityAt(this, e.getPoint());
            this.setMode(Mode.DCM_SELECT);
            this.setDirty();
            break;
        }

        case DCM_CREATE_INHERITANCE: {
            if (SwingUtilities.isLeftMouseButton(e)) {
                this.createInheritanceAt(e.getPoint());
                this.setDirty();
            }
            break;
        }

        case DCM_DRAGGING:
        case DCM_RECT_LASSO:
            // These modes are entered with a mouse press and
            // exited with mouse release, so we should not get
            // a mouse press while already in such a mode.
            // Ignore it if it happens.
            break;
        }
    }

    @Override
    public void mouseDragged(MouseEvent e) {
        this.eventReceived(e);

        if (this.mode == Mode.DCM_DRAGGING) {
            this.selfCheck();

            // Where are we going to move the dragged object's main point?
            Point destLoc = GeomUtil.subtract(e.getPoint(), this.dragOffset);

            // Snap if Shift not held.
            if (!SwingUtil.shiftPressed(e)) {
                destLoc = GeomUtil.snapPoint(destLoc, SNAP_DIST);
            }

            if (this.dragging.isSelected()) {
                // How far are we going to move the dragged object?
                Point delta = GeomUtil.subtract(destLoc, this.dragging.getLoc());

                // Move all selected controls by that amount.
                for (Controller c : this.controllers) {
                    if (!c.isSelected()) {
                        continue;
                    }

                    Point cur = c.getLoc();
                    c.dragTo(GeomUtil.add(cur, delta));
                }
            } else {
                // Dragging item is not selected; must be a resize handle.
                this.dragging.dragTo(destLoc);
            }

            this.repaint();
        }

        if (this.mode == Mode.DCM_RECT_LASSO) {
            this.lassoEnd = e.getPoint();
            this.selectAccordingToLasso();
            this.repaint();
        }
    }

    @Override
    public void mouseReleased(MouseEvent e) {
        this.eventReceived(e);

        // Click+drag should only be initiated with left mouse button, so ignore
        // release of others.
        if (!SwingUtilities.isLeftMouseButton(e)) {
            return;
        }

        if (this.mode == Mode.DCM_DRAGGING || this.mode == Mode.DCM_RECT_LASSO) {
            this.setMode(Mode.DCM_SELECT);
        }
    }

    @Override
    public void mouseClicked(MouseEvent e) {
        this.eventReceived(e);

        // Double-click on control to edit it.
        if (SwingUtilities.isLeftMouseButton(e) && (e.getClickCount() == 2)) {
            Controller c = this.hitTest(e.getPoint(), null);
            if (c != null) {
                c.edit();
            }
        }
    }

    // MouseListener methods I do not care about.
    @Override
    public void mouseEntered(MouseEvent e) {
    }

    @Override
    public void mouseExited(MouseEvent e) {
    }

    // MouseMotionListener events I do not care about.
    @Override
    public void mouseMoved(MouseEvent e) {
        this.eventReceived(e);

        // Keep the focus display up to date if desired.
        if (debugFocus && e.getX() < 10) {
            this.repaint();
        }
    }

    @Override
    public void keyPressed(KeyEvent e) {
        this.eventReceived(e);

        // Note: Some of the key bindings shown in the help dialog
        // have been moved to the menu created in Ded.java.

        if (SwingUtil.controlPressed(e)) {
            switch (e.getKeyCode()) {
            case KeyEvent.VK_F:
                if (this.fpsMeasurementMode == false) {
                    this.fpsMeasurementMode = true;
                    this.fpsStartMillis = System.currentTimeMillis();
                    this.fpsFrameCount = 0;
                    this.fpsMeasurement = "(FPS measurement in progress)";
                    this.repaint();
                } else {
                    // FPS mode already active.  We get lots of key
                    // events due to auto-repeat.
                }
                break;

            case KeyEvent.VK_G:
                this.fpsMeasurementMode = false;
                break;
            }
            return;
        }

        if (SwingUtil.altPressed(e)) {
            return;
        }

        // See if the selected controller wants this keypress.
        Controller sel = this.getUniqueSelected();
        if (sel != null) {
            if (sel.keyPressed(e)) {
                return;
            }
        }

        switch (e.getKeyCode()) {
        case KeyEvent.VK_X:
            if (SwingUtil.shiftPressed(e)) {
                assert (false); // Make sure assertions are enabled.
            } else {
                throw new RuntimeException("Test exception/error message.");
            }
            break;

        case KeyEvent.VK_H:
            this.showHelpBox();
            break;

        case KeyEvent.VK_TAB:
            this.selectNextController(!SwingUtil.shiftPressed(e) /*forward*/);
            break;
        }
    }

    /** Compare Controllers by their 'getLoc()'  point, ordering them
      * first top to bottom then left to right. */
    public static class ControllerLocationComparator implements Comparator<Controller> {
        @Override
        public int compare(Controller a, Controller b) {
            Point aLoc = a.getLoc();
            Point bLoc = b.getLoc();

            int cmp = Util.compareInts(aLoc.y, bLoc.y);
            if (cmp != 0) {
                return cmp;
            }

            cmp = Util.compareInts(aLoc.x, bLoc.x);
            if (cmp != 0) {
                return cmp;
            }

            return 0;
        }
    }

    /** If a controller is selected, cycle to either the next or
      * previous depending on 'forward' (true means next).
      *
      * Otherwise, select the first controller if there is one. */
    public void selectNextController(boolean forward) {
        // Make a list of cyclable controllers.  In particular, we need
        // to ignore resize handles.
        ArrayList<Controller> controllerCycle = new ArrayList<Controller>();
        for (Controller c : this.controllers) {
            if (c.wantLassoSelection()) {
                controllerCycle.add(c);
            }
        }

        // The insertion order isn't very meaningful and cannot be
        // easily changed by the user.  So, instead, sort by the
        // controllers' locations to make the cycle order more
        // predictable.
        Collections.sort(controllerCycle, new ControllerLocationComparator());

        // Locate the currently selected controller in the sorted cycle.
        int curSelIndex = controllerCycle.indexOf(this.getUniqueSelected());
        if (curSelIndex == -1) {
            // Nothing selected, start with first controller if
            // there is one.
            if (!controllerCycle.isEmpty()) {
                selectOnly(controllerCycle.get(0));
            }
            return;
        }

        // Compute index of next to select, cycling as necessary.
        // The extra size() term is to ensure the dividend does not
        // become negative.
        int nextSelIndex = (controllerCycle.size() + curSelIndex + (forward ? +1 : -1)) % controllerCycle.size();

        // Select it.
        selectOnly(controllerCycle.get(nextSelIndex));
    }

    /** Show the box with the key bindings. */
    public void showHelpBox() {
        JOptionPane.showMessageDialog(this, helpMessage, "Diagram Editor Keybindings",
                JOptionPane.INFORMATION_MESSAGE);
    }

    /** Show a window with the log. */
    public void showLogWindow() {
        SwingUtil.logFileMessageBox(this, this.logMessages.toString(), "Diagram Editor Log");
    }

    /** Clear the current diagram. */
    public void newFile() {
        if (this.isDirty()) {
            int res = JOptionPane.showConfirmDialog(this, "There are unsaved changes.  Create new diagram anyway?",
                    "New Diagram Confirmation", JOptionPane.YES_NO_OPTION);
            if (res != JOptionPane.YES_OPTION) {
                return;
            }
        }

        // Reset file status.
        this.dirty = false;
        this.setFileName("");

        // Clear the diagram.
        this.setDiagram(new Diagram());
    }

    /** Change the Diagram to an entirely new one. */
    private void setDiagram(Diagram newDiagram) {
        this.diagram = newDiagram;

        this.rebuildControllers();
        this.dedWindow.updateMenuState();
        this.repaint();
    }

    /** Prompt for a file name to load, then replace the current diagram with it. */
    public void loadFromFile() {
        if (this.isDirty()) {
            int res = JOptionPane.showConfirmDialog(this, "There are unsaved changes.  Load new diagram anyway?",
                    "Load Confirmation", JOptionPane.YES_NO_OPTION);
            if (res != JOptionPane.YES_OPTION) {
                return;
            }
        }

        JFileChooser chooser = new JFileChooser();
        chooser.setCurrentDirectory(this.currentFileChooserDirectory);
        chooser.addChoosableFileFilter(
                new FileNameExtensionFilter("Diagram and ER Editor Files (.ded, .png, .er)", "ded", "png", "er"));
        int res = chooser.showOpenDialog(this);
        if (res == JFileChooser.APPROVE_OPTION) {
            this.currentFileChooserDirectory = chooser.getCurrentDirectory();
            this.loadFromNamedFile(chooser.getSelectedFile().getAbsolutePath());
        }
    }

    /** Load from the given file, replacing the current diagram. */
    public void loadFromNamedFile(String name) {
        if (!new File(name).exists()) {
            SwingUtil.errorMessageBox(this, "File \"" + name + "\" does not exist.");
            return;
        }

        try {
            Diagram d;

            // See if this is a PNG file with a DED-created comment.
            if (name.endsWith(".png") || name.endsWith(".PNG")) {
                d = loadFromPNG(name);
                if (d == null) {
                    return; // canceled, or error already reported
                }
            } else {
                // For compatibility with the C++ implementation, start
                // by trying to read it in the ER format.
                d = Diagram.readFromERFile(name);
                if (d != null) {
                    // Success; but we need to indicate that the file will
                    // be saved in a different format, lest people lose
                    // their original file unexpectedly.
                    this.importedFile = true;
                } else {
                    // Read the file as JSON.
                    d = Diagram.readFromFile(name);
                    this.importedFile = false;
                }

                // Success.  Update file name.
                this.dirty = false;
                this.setFileName(name);
            }

            // Sizing is achieved by specifying a preferred size for
            // the content pane, then packing other controls and the
            // window border stuff around it.
            this.setPreferredSize(d.windowSize);
            this.dedWindow.pack();

            // Swap in the new diagram and rebuild the UI for it.
            this.setDiagram(d);
        } catch (Exception e) {
            this.exnErrorMessageBox("Error while reading \"" + name + "\"", e);
        }
    }

    /** Try to load a diagram by reading the JSON out of the comment
      * section of the PNG file 'pngName'.  If that succeeds, return
      * non-null, and also set:
      *
      *   * this.importedFile
      *   * this.dirty
      *   * this.fileName
      *
      * Return null if this failed but we already explained the problem
      * to the user, or the user cancels; or throw an exception otherwise. */
    private Diagram loadFromPNG(String pngName) throws Exception {
        // Get the name of the image source file.
        String sourceFileName = pngName.substring(0, pngName.length() - 4);
        File sourceFile = new File(sourceFileName);
        if (sourceFile.exists()) {
            if (SwingUtil.confirmationBox(this,
                    "You are trying to open a diagram PNG file \"" + pngName + "\", but the source DED file \""
                            + sourceFileName + "\" is right next to it.  Usually, you should open "
                            + "the source file instead.  Otherwise, that source file "
                            + "will be overwritten when you next save.  Are you sure you want to "
                            + "read the diagram out of the PNG comment?",
                    "Are you sure?", JOptionPane.YES_NO_OPTION) != JOptionPane.YES_OPTION) {
                return null;
            }
        }

        // Try to read the comment from the file.  If the file is corrupt
        // or cannot be read, this will throw.  But if the comment is merely
        // absent, then this will return null.
        String comment = ImageFileUtil.getPNGComment(new File(pngName));
        if (comment == null || comment.isEmpty()) {
            SwingUtil.errorMessageBox(this, "The PNG file \"" + pngName + "\" does not contain a comment, "
                    + "so it is not possible to read the diagram source from it.");
            return null;
        }

        // Do a preliminary sanity check on the comment.
        if (!comment.startsWith("{")) {
            SwingUtil.errorMessageBox(this,
                    "The PNG file \"" + pngName + "\" contains a comment, "
                            + "but it does not begin with '{', so it is not a comment " + "created by DED, "
                            + "so it is not possible to read the diagram source from it.");
            return null;
        }

        // Try parsing the comment as diagram JSON.
        Diagram d;
        try {
            d = Diagram.readFromReader(new StringReader(comment));
        } catch (Exception e) {
            this.exnErrorMessageBox("The PNG file \"" + pngName + "\" has a comment that might "
                    + "have been created by DED, but parsing that comment as " + "a diagram source file failed", e);
            return null;
        }

        // That worked.  Update the editor state variables.
        this.importedFile = false;

        // Note: We chop off ".png" and treat that as the name for
        // subsequent saves.
        this.setFileName(sourceFileName);

        // The file is not considered dirty because they are no
        // unsaved changes, even though the next save may cause
        // on-disk changes due to overwriting a source file if the
        // user ignored the warning above.
        this.dirty = false;

        return d;
    }

    /** Rebuild all the controllers from 'diagram'. */
    private void rebuildControllers() {
        this.controllers.clear();

        for (Entity e : this.diagram.entities) {
            this.buildEntityController(e);
        }

        for (Relation r : this.diagram.relations) {
            this.buildRelationController(r);
        }

        for (Inheritance inh : this.diagram.inheritances) {
            this.buildInheritanceController(inh);
        }

        this.setMode(Mode.DCM_SELECT);
    }

    /** Prompt user for file name and save to it. */
    public void chooseAndSaveToFile() {
        // Prompt for a file name, confirming if the file already exists.
        String result = this.fileName;
        while (true) {
            JFileChooser chooser = new JFileChooser();
            chooser.setCurrentDirectory(this.currentFileChooserDirectory);
            chooser.addChoosableFileFilter(new FileNameExtensionFilter("Diagram Editor Files (.ded)", "ded"));
            int res = chooser.showSaveDialog(this);
            if (res != JFileChooser.APPROVE_OPTION) {
                return;
            }
            this.currentFileChooserDirectory = chooser.getCurrentDirectory();
            result = chooser.getSelectedFile().getAbsolutePath();

            if (new File(result).exists()) {
                res = JOptionPane.showConfirmDialog(this,
                        "A file called \"" + result + "\" already exists.  Overwrite it?", "Confirm Overwrite",
                        JOptionPane.YES_NO_OPTION);
                if (res != JOptionPane.YES_OPTION) {
                    continue; // Ask again.
                }
            }

            break;
        }

        // Save to the chosen file.
        this.saveToNamedFile(result);
    }

    /** Save to the current file name.  If there is no file name,
      * prompt for a name. */
    public void saveCurrentFile() {
        if (this.fileName.isEmpty()) {
            this.chooseAndSaveToFile();
        } else {
            if (this.importedFile) {
                int res = SwingUtil.confirmationBox(this, "This diagram was loaded from \"" + this.fileName
                        + "\", which uses the old binary ER format from the "
                        + "C++ ERED implementation.  If you save the file, it "
                        + "will be overwritten with the new JSON-based format "
                        + "used by the Java-based Diagram Editor, which the " + "C++ ERED cannot read.\n" + "\n"
                        + "In order to avoid confusion, it is probably best to "
                        + "save the new file with the \".ded\" extension rather "
                        + "than the traditional \".er\" extension so that others "
                        + "will know to use Ded to read it.\n" + "\n" + "Overwrite with the new format anyway?",
                        "Confirm Overwrite of Imported File", JOptionPane.YES_NO_OPTION);
                if (res != JOptionPane.YES_OPTION) {
                    return;
                }
            }

            this.saveToNamedFile(this.fileName);
        }
    }

    /** Save to the specified file. */
    private void saveToNamedFile(String fname) {
        try {
            this.diagram.saveToFile(fname);
        } catch (Exception e) {
            this.exnErrorMessageBox("Error while saving \"" + fname + "\"", e);
            return;
        }

        // If it worked, remember the new name.
        this.dirty = false;
        this.importedFile = false;
        this.setFileName(fname);

        // Additionally, always export to PNG.
        String pngFname = fname + ".png";
        try {
            // I will save the document source JSON as a comment in the image
            // file so if the source gets separated, I can still edit
            // the image.  One place this really helps is with diagrams
            // on a wiki: there is no easy way to upload both an image
            // and its source, nor even uninterpreted source files alone
            // for that matter.  It also helps with email attachments,
            // where again it is awkward to send pairs of files.

            // First, get the JSON as a string.
            String comment = this.diagram.toJSONString();

            // Now, this string might contain non-ASCII characters inside
            // the JSON strings.  They need to be changed to use JSON
            // escapes to conform to the requirements of comments in PNG
            // files.
            StringBuilder sb = new StringBuilder();
            for (int i = 0; i < comment.length(); i++) {
                char c = comment.charAt(i);
                if (c >= 127) {
                    // Render this using a JSON escape sequence.  (We
                    // simply assume that non-ASCII characters will only
                    // appear inside quoted strings.)
                    //
                    // JSON escapes use UTF-16 code units, with all the
                    // surrogate pair ugliness, just like Java Strings,
                    // so there is no transformation to do on them.
                    sb.append(String.format("\\u%04X", (int) c));
                } else {
                    // Note that 'c' here will be printable because the
                    // procedure for rendering JSON as a string already
                    // maps the control characters to escape sequences.
                    sb.append(c);
                }
            }

            // Write the image to the PNG file, including with the comment.
            writeToPNG(new File(pngFname), sb.toString());
        } catch (Exception e) {
            this.exnErrorMessageBox("The primary diagram file \"" + fname + "\" was saved successfully, "
                    + "but exporting the PNG to \"" + pngFname + "\" failed", e);
        }
    }

    /** Change the recent file name to 'name', updating window title too. */
    private void setFileName(String name) {
        this.fileName = name;
        this.updateWindowTitle();

        // Changing the file name affects the drawn name in the
        // main editing area (if enabled).
        this.repaint();
    }

    /** Write the diagram in PNG format to 'file' with an optional comment.
      * The comment must only use ASCII characters. */
    public void writeToPNG(File file, String comment) throws Exception {
        // For a large-ish diagram, this operation takes ~200ms.  For now,
        // I will just acknowledge the delay.  An idea for the future is
        // to add a status bar to the UI, then do the export work in a
        // separate thread, with an indicator in the status bar that will
        // reflect when the operation completes.  I consider it important
        // for the user to know when it finishes so if it takes a long
        // time, they don't in the meantime go copy or view the partially
        // written image.
        this.setCursor(Cursor.getPredefinedCursor(Cursor.WAIT_CURSOR));
        try {
            // Based on code from:
            // http://stackoverflow.com/questions/5655908/export-jpanel-graphics-to-png-or-gif-or-jpg

            // First, render the image to an in-memory image buffer.
            BufferedImage bi = new BufferedImage(this.getSize().width, this.getSize().height,
                    BufferedImage.TYPE_INT_ARGB);
            Graphics g = bi.createGraphics();
            this.paintWithoutSelectionsShowing(g);
            g.dispose();

            // Now, write that image to a file in PNG format.
            String warning = ImageFileUtil.writeImageToPNGFile(bi, file, comment);
            if (warning != null) {
                SwingUtil.warningMessageBox(this, "File save completed successfully, but while exporting to PNG, "
                        + "there was a warning: " + warning);
            }
        } finally {
            this.setCursor(Cursor.getDefaultCursor());
        }
    }

    /** Paint diagram to 'g', except temporarily deselect everything
      * first so that the selection indicators do not not appear. */
    protected void paintWithoutSelectionsShowing(Graphics g) {
        // Turn off selections.
        HashSet<Controller> originalSelection = this.getSelectionSet();
        setMultipleSelected(originalSelection, SelectionState.SS_UNSELECTED);
        try {
            // Paint now that selections are turned off.
            //
            // This uses 'innerPaint' to bypass the additional buffering
            // logic, which is unnecessary here since we are already
            // rendering to a hidden image to write to a file.
            this.innerPaint(g);
        } finally {
            // Restore selection state.
            setSelectionSet(originalSelection);
        }
    }

    /** Check to see if the font is rendering properly.  I have had a
      * lot of trouble getting this to work on a wide range of
      * machines and JVMs.  If the font rendering does not work, just
      * alert the user to the problem but keep going. */
    public void checkFontRendering() {
        // Render the glyph for 'A' in a box just large enough to
        // contain it when rendered properly.
        int width = 9;
        int height = 11;
        BufferedImage bi = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
        Graphics g = bi.createGraphics();
        ColorModel colorModel = bi.getColorModel();

        g.setColor(Color.WHITE);
        g.fillRect(0, 0, width, height);

        g.setColor(Color.BLACK);
        g.setFont(this.dedWindow.diagramFont);
        g.drawString("A", 0, 10);

        // Print that glyph as a string.
        StringBuilder sb = new StringBuilder();
        for (int y = 0; y < height; y++) {
            int bits = 0;
            for (int x = 0; x < width; x++) {
                int pixel = bi.getRGB(x, y);
                int red = colorModel.getRed(pixel);
                int green = colorModel.getGreen(pixel);
                int blue = colorModel.getBlue(pixel);
                int alpha = colorModel.getAlpha(pixel);
                boolean isWhite = (red == 255 && green == 255 && blue == 255 && alpha == 255);
                boolean isBlack = (red == 0 && green == 0 && blue == 0 && alpha == 255);
                sb.append(
                        isWhite ? "_" : isBlack ? "X" : ("(" + red + "," + green + "," + blue + "," + alpha + ")"));

                bits <<= 1;
                if (!isWhite) {
                    bits |= 1;
                }
            }
            sb.append(String.format("  (0x%03X)\n", bits));
        }

        // Also include some of the font metrics.
        FontMetrics fm = g.getFontMetrics();
        sb.append("fm: ascent=" + fm.getAscent() + " leading=" + fm.getLeading() + " charWidth('A')="
                + fm.charWidth('A') + " descent=" + fm.getDescent() + " height=" + fm.getHeight() + "\n");

        String actualGlyph = sb.toString();

        g.dispose();

        // The expected glyph and metrics.
        String expectedGlyph = "_________  (0x000)\n" + "____X____  (0x010)\n" + "___X_X___  (0x028)\n"
                + "___X_X___  (0x028)\n" + "__X___X__  (0x044)\n" + "__X___X__  (0x044)\n" + "__XXXXX__  (0x07C)\n"
                + "_X_____X_  (0x082)\n" + "_X_____X_  (0x082)\n" + "_X_____X_  (0x082)\n" + "_________  (0x000)\n"
                + "fm: ascent=10 leading=1 charWidth('A')=9 descent=3 height=14\n";

        if (!expectedGlyph.equals(actualGlyph)) {
            // Currently, this is known to happen when using OpenJDK 6
            // and 7, with 6 being close to right and 7 being very bad.
            // I also have reports of it happening on certain Mac OS/X
            // systems, but I haven't been able to determine what the
            // important factor there is.
            String warningMessage = "There is a problem with the font rendering.  The glyph "
                    + "for the letter 'A' should look like:\n" + expectedGlyph + "but it renders as:\n"
                    + actualGlyph + "\n" + "This probably means there is a bug in the TrueType "
                    + "font library.  You might try a different Java version.  "
                    + "(I'm working on how to solve this permanently.)";
            System.err.println(warningMessage);
            this.log(warningMessage);
            SwingUtil.errorMessageBox(null /*component*/, warningMessage);
        }
    }

    /** Get and log some details related to display scaling, particularly
      * to help diagnose the graphics bugs on HiDPI/Retina displays. */
    public void logDisplayScaling() {
        // Based on code from
        // http://lubosplavucha.com/java/2013/09/02/retina-support-in-java-for-awt-swing/

        try {
            // Dump a bunch of possibly interesting JVM properties.
            String propertyNames[] = { "awt.toolkit", "java.awt.graphicsenv", "java.runtime.name",
                    "java.runtime.version", "java.vendor", "java.version", "java.vm.name", "java.vm.vendor",
                    "java.vm.version", };
            for (String name : propertyNames) {
                this.log("property " + name + ": " + System.getProperty(name));
            }

            // Try a property specific to the Apple JVM.
            this.log("apple.awt.contentScaleFactor: "
                    + Toolkit.getDefaultToolkit().getDesktopProperty("apple.awt.contentScaleFactor"));

            // Try something specific to OpenJDK.  Here, we
            // reflectively query some private field.  Yuck.
            GraphicsDevice gd = GraphicsEnvironment.getLocalGraphicsEnvironment().getDefaultScreenDevice();
            try {
                Field field = gd.getClass().getDeclaredField("scale");
                field.setAccessible(true);
                this.log("GraphicsEnvironment.scale: " + field.get(gd));
            } catch (NoSuchFieldException e) {
                this.log("GraphicsEnvironment does not have a 'scale' field");
            }

            // Check some details of "compatible" images.
            GraphicsConfiguration gc = gd.getDefaultConfiguration();
            BufferedImage bi = gc.createCompatibleImage(64, 64);
            ColorModel cm = bi.getColorModel();
            this.log("compatible image color model: " + cm);

            // Do the same for a specific imageType that seems to be
            // commonly used, and that I am using when saving to PNG.
            bi = new BufferedImage(64, 64, BufferedImage.TYPE_INT_ARGB);
            cm = bi.getColorModel();
            this.log("TYPE_INT_ARGB color model: " + cm);

            // And one more.
            bi = new BufferedImage(64, 64, BufferedImage.TYPE_INT_RGB);
            cm = bi.getColorModel();
            this.log("TYPE_INT_RGB color model: " + cm);
        } catch (Exception e) {
            this.log("exception during logDisplayScaling(): " + Util.getExceptionMessage(e));
            this.logNoNewline(Util.getExceptionStackTrace(e));
        }
    }

    /** Called when the diagram has been changed.  This does a repaint
      * and sets the dirty bit. */
    public void diagramChanged() {
        this.setDirty();
        this.repaint();
    }

    /** Set 'dirty' to true. */
    public void setDirty() {
        if (!this.dirty) {
            this.dirty = true;
            this.updateWindowTitle();
        }
    }

    /** Clear the dirty bit. */
    public void clearDirty() {
        if (this.dirty) {
            this.dirty = false;
            this.updateWindowTitle();
        }
    }

    /** Return true if 'dirty'. */
    public boolean isDirty() {
        return this.dirty;
    }

    /** Set the window title to match current state. */
    private void updateWindowTitle() {
        String title = Ded.windowTitle;

        if (!this.fileName.isEmpty()) {
            // Do not include the directory here.
            title += ": " + new File(this.fileName).getName();
        }

        if (this.importedFile) {
            title += " (imported)";
        }

        if (this.dirty) {
            title += " *";
        }

        this.dedWindow.setTitle(title);
    }

    // KeyListener methods I do not care about.
    @Override
    public void keyTyped(KeyEvent e) {
    }

    @Override
    public void keyReleased(KeyEvent e) {
    }

    /** Change the UI mode to 'm', maintaining a few invariants in the process. */
    public void setMode(Mode m) {
        this.mode = m;

        if (m != Mode.DCM_DRAGGING) {
            if (this.dragging != null) {
                this.dragging.stopDragging();
            }
            this.dragging = null;
            this.dragOffset = new Point(0, 0);
        }

        if (m != Mode.DCM_RECT_LASSO) {
            this.lassoStart = this.lassoEnd = new Point(0, 0);
            this.lassoOriginalSelected.clear();
        }

        switch (m) {
        default:
            // I tried crosshair for lasso, but that is too annoying
            // when just clicking in empty space.  I also tried the
            // "move" cursor for dragging, but that cursor blocks too
            // much of the view of the area right under what is being
            // moved, making precise positioning difficult.
            //
            // Basically, I don't really need a different cursor when
            // the mouse button is pressed because the user has already
            // initiated an action and is therefore aware that something
            // unusual is happening.  And in most other cases, I don't
            // need a special cursor because the effect of pressing the
            // mouse is fairly obvious already.
            this.setCursor(Cursor.getDefaultCursor());
            break;

        case DCM_CREATE_ENTITY:
        case DCM_CREATE_INHERITANCE:
        case DCM_CREATE_RELATION:
            // The crosshair here is not particularly suggestive of
            // what the mode does, but it is noticeably different,
            // which clues the user to the altered behavior.
            this.setCursor(Cursor.getPredefinedCursor(Cursor.CROSSHAIR_CURSOR));
            break;
        }

        this.selfCheck();
        this.repaint();
    }

    /** Construct a controller for 'e' and add it to 'this'. */
    private EntityController buildEntityController(Entity e) {
        EntityController ec = new EntityController(this, e);
        this.add(ec);
        return ec;
    }

    /** Construct a controller for 'r' and add it to 'this'. */
    private RelationController buildRelationController(Relation r) {
        RelationController rc = new RelationController(this, r);
        this.add(rc);
        return rc;
    }

    /** Construct a controller for 'inh' and add it to 'this'. */
    private InheritanceController buildInheritanceController(Inheritance inh) {
        InheritanceController ic = new InheritanceController(this, inh);
        this.add(ic);
        return ic;
    }

    /** If there is exactly one controller selected, return it; otherwise
      * return null. */
    public Controller getUniqueSelected() {
        Controller ret = null;

        for (Controller c : this.controllers) {
            if (c.isSelected()) {
                if (ret != null) {
                    return null; // More than one is selected.
                }
                ret = c;
            }
        }

        return ret;
    }

    /** Get all selected controllers. */
    public IdentityHashSet<Controller> getAllSelected() {
        return this.findControllers(new ControllerFilter() {
            public boolean satisfies(Controller c) {
                return c.isSelected();
            }
        });
    }

    /** Edit the selected controller and associated entity, if any. */
    public void editSelected() {
        if (this.mode == Mode.DCM_SELECT) {
            Controller c = this.getUniqueSelected();
            if (c != null) {
                c.edit();
            } else {
                this.errorMessageBox("There must be exactly one thing selected to edit it.");
            }
        }
    }

    /** Copy the selected entities to the (application) clipboard. */
    public void copySelected() {
        IdentityHashSet<Controller> selControllers = this.getAllSelected();
        if (selControllers.isEmpty()) {
            this.errorMessageBox("Nothing is selected to copy.");
            return;
        }

        // Collect all the selected elements.
        IdentityHashSet<Entity> selEntities = new IdentityHashSet<Entity>();
        IdentityHashSet<Inheritance> selInheritances = new IdentityHashSet<Inheritance>();
        IdentityHashSet<Relation> selRelations = new IdentityHashSet<Relation>();
        for (Controller c : selControllers) {
            if (c instanceof EntityController) {
                selEntities.add(((EntityController) c).entity);
            }
            if (c instanceof InheritanceController) {
                selInheritances.add(((InheritanceController) c).inheritance);
            }
            if (c instanceof RelationController) {
                selRelations.add(((RelationController) c).relation);
            }
        }

        // Map from elements in the original to their counterpart in the copy.
        IdentityHashMap<Entity, Entity> entityToCopy = new IdentityHashMap<Entity, Entity>();
        IdentityHashMap<Inheritance, Inheritance> inheritanceToCopy = new IdentityHashMap<Inheritance, Inheritance>();

        // Construct a new Diagram with just the selected elements.
        Diagram copy = new Diagram();
        for (Entity e : selEntities) {
            Entity eCopy = new Entity(e);
            entityToCopy.put(e, eCopy);
            copy.entities.add(eCopy);
        }
        for (Inheritance i : selInheritances) {
            // See if the parent entity is among those we are copying.
            Entity parentCopy = entityToCopy.get(i.parent);
            if (parentCopy == null) {
                // No, so we'll skip the inheritance too.
            } else {
                Inheritance iCopy = new Inheritance(i, parentCopy);
                inheritanceToCopy.put(i, iCopy);
                copy.inheritances.add(iCopy);
            }
        }
        for (Relation r : selRelations) {
            RelationEndpoint startCopy = copyRelationEndpoint(r.start, entityToCopy, inheritanceToCopy);
            RelationEndpoint endCopy = copyRelationEndpoint(r.end, entityToCopy, inheritanceToCopy);
            if (startCopy == null || endCopy == null) {
                // Skip the relation.
            } else {
                copy.relations.add(new Relation(r, startCopy, endCopy));
            }
        }

        // Make sure the Diagram is well-formed.
        try {
            // This is quadratic...
            copy.selfCheck();
        } catch (Throwable t) {
            this.errorMessageBox("Internal error: failed to create a well-formed copy: " + t);
            return;
        }

        // Copy it as a string to the system clipboard.
        StringSelection data = new StringSelection(copy.toJSONString());
        Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
        clipboard.setContents(data, data);
        clipboard = Toolkit.getDefaultToolkit().getSystemSelection();
        if (clipboard != null) {
            clipboard.setContents(data, data);
        }
    }

    /** Make a copy of 'src', taking advantage of the maps to corresponding
      * entities and inheritances already copied. */
    private static RelationEndpoint copyRelationEndpoint(RelationEndpoint src,
            IdentityHashMap<Entity, Entity> entityToCopy,
            IdentityHashMap<Inheritance, Inheritance> inheritanceToCopy) {
        RelationEndpoint ret;

        if (src.entity != null) {
            Entity eCopy = entityToCopy.get(src.entity);
            if (eCopy == null) {
                // Counterpart is not copied, so we will bail on the
                // endpoint, and hence the relation too.
                return null;
            }
            ret = new RelationEndpoint(eCopy);
        }

        else if (src.inheritance != null) {
            Inheritance iCopy = inheritanceToCopy.get(src.inheritance);
            if (iCopy == null) {
                return null;
            }
            ret = new RelationEndpoint(iCopy);
        }

        else {
            ret = new RelationEndpoint(new Point(src.pt));
        }

        ret.arrowStyle = src.arrowStyle;
        return ret;
    }

    /** Try to read the clipboard contents as a Diagram.  Return null and
      * display an error if we cannot. */
    private Diagram getClipboardAsDiagram() {
        // Let's start with the "selection" because if it is valid JSON
        // then it's probably what we want.
        String selErrorMessage = null;
        String selContents = null;
        Clipboard clipboard = Toolkit.getDefaultToolkit().getSystemSelection();
        if (clipboard == null) {
            // Probably we're running on something other than X Windows,
            // so we thankfully don't have to deal with the screwy
            // "selection" concept.
        } else {
            Transferable clipData = clipboard.getContents(clipboard);
            if (clipData == null) {
                selErrorMessage = "Nothing is in the \"selection\".";
            } else {
                try {
                    if (clipData.isDataFlavorSupported(DataFlavor.stringFlavor)) {
                        selContents = (String) (clipData.getTransferData(DataFlavor.stringFlavor));

                        // Try to parse it.
                        try {
                            return Diagram.parseJSONString(selContents);
                        } catch (JSONException e) {
                            selErrorMessage = "Could not parse selection data as Diagram JSON: " + e;
                        }
                    } else {
                        selErrorMessage = "The data in the selection is not a string.";
                    }
                } catch (Exception e) {
                    selErrorMessage = "Error while retrieving selection data: " + e;
                }
            }
        }

        // Now try again with the clipboard.
        String clipErrorMessage = null;
        String clipContents = null;
        clipboard = Toolkit.getDefaultToolkit().getSystemClipboard();
        if (clipboard == null) {
            clipErrorMessage = "getSystemClipboard returned null?!";
        } else {
            Transferable clipData = clipboard.getContents(clipboard);
            if (clipData == null) {
                clipErrorMessage = "Nothing is in the clipboard.";
            } else {
                try {
                    if (clipData.isDataFlavorSupported(DataFlavor.stringFlavor)) {
                        clipContents = (String) (clipData.getTransferData(DataFlavor.stringFlavor));

                        try {
                            return Diagram.parseJSONString(clipContents);
                        } catch (JSONException e) {
                            clipErrorMessage = "Could not parse clipboard data as Diagram JSON: " + e;
                        }
                    } else {
                        clipErrorMessage = "The data in the clipboard is not a string.";
                    }
                } catch (Exception e) {
                    clipErrorMessage = "Error while retrieving clipboard data: " + e;
                }
            }
        }

        // Both methods failed, and we have one or two error messages.
        // Decide what to show.
        if (selErrorMessage == null) {
            this.errorMessageBox(clipErrorMessage);
        } else if (selContents == null && clipContents != null) {
            this.errorMessageBox(clipErrorMessage);
        } else if (selContents != null && clipContents == null) {
            this.errorMessageBox(selErrorMessage);
        } else if (selContents.equals(clipContents)) {
            this.errorMessageBox(clipErrorMessage + "  (The selection and clipboard contents are the same.)");
        } else {
            this.errorMessageBox("Failed to read either the selection or the clipboard.  " + selErrorMessage + "  "
                    + clipErrorMessage);
        }

        return null;
    }

    /** Insert the clipboard contents into the diagram. */
    public void pasteClipboard() {
        // Try to parse what is in the clipboard as Diagram JSON.
        Diagram copy = this.getClipboardAsDiagram();
        if (copy == null) {
            // Already showed the error dialog.
            return;
        }

        // Prepare to select only the new controllers.
        this.deselectAll();

        // Insert the new entities, making controllers for them.
        final IdentityHashSet<Controller> newControllers = new IdentityHashSet<Controller>();
        for (Entity e : copy.entities) {
            this.diagram.entities.add(e);
            newControllers.add(this.buildEntityController(e));
        }
        for (Inheritance i : copy.inheritances) {
            this.diagram.inheritances.add(i);
            newControllers.add(this.buildInheritanceController(i));
        }
        for (Relation r : copy.relations) {
            this.diagram.relations.add(r);
            newControllers.add(this.buildRelationController(r));
        }

        // Make exactly the new controllers selected.
        this.selectAccordingToFilter(new ControllerFilter() {
            public boolean satisfies(Controller c) {
                return newControllers.contains(c);
            }
        });

        this.diagramChanged();
    }

    /** Delete the selected controllers and associated entities, if any. */
    public void deleteSelected() {
        if (this.mode == Mode.DCM_SELECT) {
            IdentityHashSet<Controller> sel = this.getAllSelected();
            int n = sel.size();
            if (n > 1) {
                int choice = JOptionPane.showConfirmDialog(this, "Delete " + n + " elements?", "Confirm Deletion",
                        JOptionPane.OK_CANCEL_OPTION);
                if (choice != JOptionPane.OK_OPTION) {
                    return;
                }
            }

            this.deleteControllers(sel);
        }
    }

    /** Insert a new control point into the selected controller and
      * associated entity, if any and applicable. */
    public void insertControlPoint() {
        if (this.mode == Mode.DCM_SELECT) {
            Controller c = this.getUniqueSelected();
            if (c != null) {
                c.insertControlPoint();
            } else {
                this.errorMessageBox("There must be exactly one thing selected to insert a control point.");
            }
        }
    }

    /** Toggle selection state of one controller. */
    public void toggleSelection(Controller c) {
        if (c.isSelected()) {
            c.setSelected(SelectionState.SS_UNSELECTED);
        } else {
            c.setSelected(SelectionState.SS_SELECTED);
        }

        this.normalizeExclusiveSelect();
        this.repaint();
    }

    /** If exactly one controller is selected, set its state to
      * SS_EXCLUSIVE; otherwise, set all selected controllers to
      * SS_SELECTED. */
    public void normalizeExclusiveSelect() {
        this.selectAccordingToFilter(new ControllerFilter() {
            public boolean satisfies(Controller c) {
                return c.isSelected();
            }
        });
    }

    /** Select a single controller. */
    public void selectOnly(Controller c) {
        this.deselectAll();
        c.setSelected(SelectionState.SS_EXCLUSIVE);
        this.repaint();
    }

    /** Change mode to DCM_DRAGGING, dragging 'c' from 'pt'. */
    public void beginDragging(Controller c, Point pt) {
        this.dragging = c;
        this.dragOffset = GeomUtil.subtract(pt, c.getLoc());
        c.beginDragging(pt);
        this.setMode(Mode.DCM_DRAGGING);
    }

    /** Check internal invariants, throw assertion failure if violated. */
    public void selfCheck() {
        if (this.mode == Mode.DCM_DRAGGING) {
            assert (this.dragging != null);
        } else {
            assert (this.dragging == null);
        }

        for (Controller c : this.controllers) {
            c.globalSelfCheck(this.diagram);
        }
    }

    /** Set the set of selected controllers to those in 'toSelect'. */
    protected void setSelectionSet(final Set<Controller> toSelect) {
        selectAccordingToFilter(new ControllerFilter() {
            public boolean satisfies(Controller c) {
                return toSelect.contains(c);
            }
        });
    }

    /** Set the set of selected controllers according to the filter. */
    protected void selectAccordingToFilter(ControllerFilter filter) {
        // During the loop, merely collect the sets of controllers
        // to select and deselect, then set the selection state afterward;
        // otherwise, we risk trying to modify the set of controllers
        // while it is being iterated over, since changing the selection
        // state of a controller can add or remove resize handles.
        HashSet<Controller> toSelect = new HashSet<Controller>();
        HashSet<Controller> toDeselect = new HashSet<Controller>();

        for (Controller c : this.controllers) {
            if (filter.satisfies(c)) {
                toSelect.add(c);
            } else if (c.isSelected()) {
                toDeselect.add(c);
            } else {
                // 'c' is not selected and should not be; just leave
                // it alone.
            }
        }

        // Deselect everything that should not be selected but
        // previously was.
        setMultipleSelected(toDeselect, SelectionState.SS_UNSELECTED);

        if (toSelect.size() == 1) {
            // Exclusively select the one lasso'd controller.  (Using
            // the "multi" call is merely syntactically convenient.)
            //
            // This will show resize controls.
            setMultipleSelected(toSelect, SelectionState.SS_EXCLUSIVE);
        } else {
            // Set state of all selected controls.
            setMultipleSelected(toSelect, SelectionState.SS_SELECTED);
        }
    }

    /** Return the current lasso rectangle. */
    protected Rectangle getLassoRect() {
        return new Rectangle(Math.min(this.lassoStart.x, this.lassoEnd.x),
                Math.min(this.lassoStart.y, this.lassoEnd.y), Math.abs(this.lassoEnd.x - this.lassoStart.x),
                Math.abs(this.lassoEnd.y - this.lassoStart.y));
    }

    /** Set the set of selected controllers according to the lasso. */
    protected void selectAccordingToLasso() {
        final Rectangle lasso = this.getLassoRect();

        this.selectAccordingToFilter(new ControllerFilter() {
            public boolean satisfies(Controller c) {
                if (!c.wantLassoSelection()) {
                    // Do not consider resize handles, mainly because doing so
                    // causes them to flicker: lassoing a single control adds
                    // resize handles, making the lasso no longer enclose just
                    // one control, which turns off resize handles, etc.
                    //
                    // I'm actually not sure how the C++ ered tool avoids this
                    // effect.  I do not see any avoidance in the code...
                    return false;
                }

                // Keep the original set, if any.
                if (DiagramController.this.lassoOriginalSelected.contains(c)) {
                    return true;
                }

                return c.boundsIntersects(lasso);
            }
        });
    }

    /** Add an active controller. */
    public void add(Controller c) {
        this.controllers.add(c);
        this.repaint();
    }

    /** Remove an active controller. */
    public void remove(Controller c) {
        this.controllers.remove(c);
        this.repaint();
    }

    /** Return true if 'c' is among the active controllers for this diagram. */
    public boolean contains(Controller c) {
        return this.controllers.contains(c);
    }

    /** Return set of matching controllers. */
    private IdentityHashSet<Controller> findControllers(ControllerFilter filter) {
        IdentityHashSet<Controller> ret = new IdentityHashSet<Controller>();
        for (Controller c : this.controllers) {
            if (filter.satisfies(c)) {
                ret.add(c);
            }
        }
        return ret;
    }

    /** Delete matching controllers. */
    public void deleteControllers(ControllerFilter filter) {
        // Get the set of matching controllers first; we cannot remove
        // them while searching due to iterator invalidation issues.
        IdentityHashSet<Controller> ctls = this.findControllers(filter);

        this.deleteControllers(ctls);
    }

    /** Delete specified controllers. */
    public void deleteControllers(IdentityHashSet<Controller> ctls) {
        for (Controller c : ctls) {
            // This is inefficient, but oh well: before deleting, check
            // if it is still in 'controllers'.  It might have been removed
            // due to deletion of another controller in 'ctls'.
            if (this.controllers.contains(c)) {
                c.deleteSelfAndData(this.diagram);
            }
            this.setDirty();
        }
    }

    /** Find EntityControllers fully contained in a rectangle. */
    public IdentityHashSet<EntityController> findEntityControllersInRectangle(Rectangle rect) {
        IdentityHashSet<EntityController> ret = new IdentityHashSet<EntityController>();
        for (Controller c : this.controllers) {
            if (c instanceof EntityController) {
                EntityController ec = (EntityController) c;
                if (rect.contains(ec.getRect())) {
                    ret.add(ec);
                }
            }
        }
        return ret;
    }

    /** Map a Point to a RelationEndpoint: either an Entity or Inheritance
      * that contains the Point, or else just the point itself. */
    public RelationEndpoint getRelationEndpoint(Point pt) {
        // Entity?
        EntityController ec = this.hitTestEntity(pt);
        if (ec != null) {
            return new RelationEndpoint(ec.entity);
        }

        // Inheritance?
        InheritanceController ic = this.hitTestInheritance(pt);
        if (ic != null) {
            return new RelationEndpoint(ic.inheritance);
        }

        // No suitable intersecting controller, use the point itself.
        return new RelationEndpoint(pt);
    }

    /** Create an inheritance based on the user's click on 'point'. */
    private void createInheritanceAt(Point point) {
        // Must be clicking on an entity.
        EntityController parent = this.hitTestEntity(point);
        if (parent == null) {
            this.errorMessageBox(
                    "To create an inheritance, start by clicking " + "and dragging on the parent Entity.");
            return;
        }

        // Make an Inheritance connected to 'parent' and
        // current location.
        Inheritance inh = new Inheritance(parent.entity, false /*open*/, point);
        this.diagram.inheritances.add(inh);
        this.setDirty();

        // Build and select a controller.
        InheritanceController ic = buildInheritanceController(inh);
        this.selectOnly(ic);

        // Drag it while the mouse button is pressed.
        this.beginDragging(ic, point);

        this.repaint();
    }

    /** Change the selected entities' fill colors to the named color. */
    public void setSelectedEntitiesFillColor(String colorName) {
        // Iterate over selected entities, changing their color.
        for (Controller c : this.controllers) {
            if (c.isSelected() && c instanceof EntityController) {
                EntityController ec = (EntityController) c;
                ec.entity.setFillColor(colorName);
            }
        }

        this.diagramChanged();
    }

    /** Change the selected entities' shapes to the indicated shape. */
    public void setSelectedEntitiesShape(EntityShape shape) {
        for (Controller c : this.controllers) {
            if (c.isSelected() && c instanceof EntityController) {
                EntityController ec = (EntityController) c;
                ec.entity.setShape(shape);
            }
        }

        this.diagramChanged();
    }

    public static enum SetAnchorCommand {
        // Set the anchor name to equal the entity name.
        SAC_SET_TO_ENTITY_NAME,

        // Clear the anchor name.
        SAC_CLEAR
    }

    /** Change the selected entities' anchor names in accordance with
      * 'command'. */
    public void setSelectedEntitiesAnchorName(SetAnchorCommand command) {
        int count = 0;
        for (Controller c : this.controllers) {
            if (c.isSelected() && c instanceof EntityController) {
                EntityController ec = (EntityController) c;
                switch (command) {
                case SAC_SET_TO_ENTITY_NAME:
                    ec.entity.anchorName = ec.entity.name;
                    break;

                case SAC_CLEAR:
                    ec.entity.anchorName = "";
                    break;
                }
                count++;
            }
        }

        if (count == 0) {
            errorMessageBox("There were no selected entities?  Should not happen.");
        } else {
            SwingUtil.informationMessageBox(this, "Updated entities",
                    "Updated the anchor names of " + count + " entities.");
        }
        this.diagramChanged();
    }

    /** Show an error message dialog box with 'message'. */
    public void errorMessageBox(String message) {
        SwingUtil.errorMessageBox(this, message);
    }

    /** Show an error message arising from Exception 'e'. */
    public void exnErrorMessageBox(String context, Exception e) {
        errorMessageBox(context + ": " + Util.getExceptionMessage(e));
    }

    /** Get an image for a given file name.  Save the result in an
      * image cache.  Return null if it cannot be loaded. */
    public Image getImage(String imageFileName) {
        // Consult the cache.
        if (this.imageCache.containsKey(imageFileName)) {
            return this.imageCache.get(imageFileName); // Might be null.
        }

        // Try to load the image from disk.
        Image image = this.innerGetImage(imageFileName);

        // Cache the result, whatever it was, even if null.
        this.imageCache.put(imageFileName, image);

        return image;
    }

    /** Get an image for a file name, not using the cache.  If there
      * is problem, log it and return null. */
    private Image innerGetImage(String imageFileName) {
        // What directory will we interpret a relative name as relative to?
        File relativeBase;
        if (this.fileName.isEmpty()) {
            // Use current working directory.
            relativeBase = Util.getWorkingDirectoryFile();
        } else {
            // Use the directory containing the diagram file.
            relativeBase = new File(this.fileName).getParentFile();
        }

        // Combine the base with the specified file.
        File imageFile = Util.getFileRelativeTo(relativeBase, imageFileName);

        // Try to load the file.
        FileInputStream is = null;
        try {
            // I explicitly create my own InputStream because ImageIO
            // does a poor job of reporting file read errors.
            is = new FileInputStream(imageFile);
            Image image = ImageIO.read(is);
            if (image == null) {
                this.log("no registered image reader for: " + imageFile);
                return null;
            }

            this.log("loaded: " + imageFileName);
            return image;
        } catch (Exception e) {
            this.log("while loading \"" + imageFileName + "\": " + Util.getExceptionMessage(e));
            return null;
        } finally {
            if (is != null) {
                try {
                    is.close();
                } catch (IOException e) {
                    /*ignore*/}
            }
        }
    }

    /** Clear the image cache and redraw so we reload the images. */
    public void reloadEntityImages() {
        this.log("image cache cleared at " + (new Date()));

        this.imageCache.clear();

        // Reloading images might alter size-locked entity sizes.
        for (Controller c : this.controllers) {
            c.updateAfterImageReload();
        }

        this.repaint();
    }

    /** Log the given one-line message.  A newline will be added after
      * it to mark its end in the total accumulated log. */
    public void log(String message) {
        this.logNoNewline(message + "\n");
    }

    /** Add a string to the accumulated log without adding a newline.
      * This is appropriate when the message may have multiple lines,
      * all of which are already newline-terminated. */
    public void logNoNewline(String message) {
        this.logMessages.append(message);
    }

    /** If 'front' is true, make selected entities top-most so they
      * are displayed on top of all others, preserving the relative
      * order of the selected entities.  If 'front' is false, move
      * them to the bottom. */
    public void moveSelectedEntitiesToFrontOrBack(boolean front) {
        // Collect the selected entities and controllers in their
        // current relative order.  I need 'selEntities' so I can
        // call 'removeAll' and 'addAll' with them.
        ArrayList<Entity> selEntities = new ArrayList<Entity>();
        ArrayList<EntityController> selControllers = new ArrayList<EntityController>();
        for (Controller c : this.controllers) {
            if (c.isSelected() && c instanceof EntityController) {
                EntityController ec = (EntityController) c;
                selEntities.add(ec.entity);
                selControllers.add(ec);
            }
        }

        if (selEntities.isEmpty()) {
            this.errorMessageBox("There are no selected entities to move.");
            return;
        }

        // Move the entities in the diagram.
        this.diagram.entities.removeAll(selEntities);
        if (front) {
            this.diagram.entities.addAll(selEntities);
        } else {
            this.diagram.entities.addAll(0, selEntities);
        }

        // Move the controller as well.
        this.controllers.removeAll(selControllers);
        if (front) {
            this.controllers.addAll(selControllers);
        } else {
            this.controllers.addAll(0, selControllers);
        }

        this.selfCheck();
        this.diagramChanged();
    }

    @Override
    public void componentResized(ComponentEvent e) {
        this.diagram.windowSize = this.getSize();

        // I do not set the dirty bit here because resizing is not a
        // very important action, and I'm having some trouble avoiding
        // making things dirty on startup.
    }

    // ComponentListener events I do not care about.
    @Override
    public void componentMoved(ComponentEvent e) {
    }

    @Override
    public void componentShown(ComponentEvent e) {
    }

    @Override
    public void componentHidden(ComponentEvent e) {
    }

    @Override
    public void focusGained(FocusEvent e) {
        if (debugFocus) {
            System.out.println("DiagramController focusGained: " + e);
            this.repaint();
        }
    }

    @Override
    public void focusLost(FocusEvent e) {
        if (debugFocus) {
            System.out.println("DiagramController focusLost: " + e);
            this.repaint();
        }
    }

    /** Return a resource image, using an internal cache. */
    public Image getResourceImage(String resourceName) {
        return this.dedWindow.resourceImageCache.getResourceImage(resourceName);
    }
}

// EOF