com.t3.client.AppActions.java Source code

Java tutorial

Introduction

Here is the source code for com.t3.client.AppActions.java

Source

/*
 * Copyright (c) 2014 tabletoptool.com team.
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the GNU Public License v3.0
 * which accompanies this distribution, and is available at
 * http://www.gnu.org/licenses/gpl.html
 * 
 * Contributors:
 *     rptools.com team - initial implementation
 *     tabletoptool.com team - further development
 */
package com.t3.client;

import java.awt.Dimension;
import java.awt.Event;
import java.awt.Graphics2D;
import java.awt.Image;
import java.awt.Toolkit;
import java.awt.Transparency;
import java.awt.event.ActionEvent;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.UnknownHostException;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Observer;
import java.util.Set;
import java.util.zip.GZIPOutputStream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

import javax.swing.AbstractAction;
import javax.swing.Action;
import javax.swing.ImageIcon;
import javax.swing.JButton;
import javax.swing.JComponent;
import javax.swing.JFileChooser;
import javax.swing.JOptionPane;
import javax.swing.KeyStroke;
import javax.swing.SwingWorker;

import org.apache.commons.io.FileUtils;
import org.apache.log4j.Logger;

import com.jidesoft.docking.DockableFrame;
import com.t3.MD5Key;
import com.t3.client.tool.BoardTool;
import com.t3.client.tool.GridTool;
import com.t3.client.ui.AddResourceDialog;
import com.t3.client.ui.AppMenuBar;
import com.t3.client.ui.ClientConnectionPanel;
import com.t3.client.ui.ConnectToServerDialog;
import com.t3.client.ui.ConnectToServerDialogPreferences;
import com.t3.client.ui.ConnectionInfoDialog;
import com.t3.client.ui.ConnectionStatusPanel;
import com.t3.client.ui.ExportDialog;
import com.t3.client.ui.MapPropertiesDialog;
import com.t3.client.ui.PreferencesDialog;
import com.t3.client.ui.PreviewPanelFileChooser;
import com.t3.client.ui.StartServerDialog;
import com.t3.client.ui.StartServerDialogPreferences;
import com.t3.client.ui.StaticMessageDialog;
import com.t3.client.ui.T3Frame;
import com.t3.client.ui.T3Frame.MTFrame;
import com.t3.client.ui.assetpanel.AssetPanel;
import com.t3.client.ui.assetpanel.Directory;
import com.t3.client.ui.campaignproperties.CampaignPropertiesDialog;
import com.t3.client.ui.io.FTPClient;
import com.t3.client.ui.io.FTPTransferObject;
import com.t3.client.ui.io.FTPTransferObject.Direction;
import com.t3.client.ui.io.ProgressBarList;
import com.t3.client.ui.io.UpdateRepoDialog;
import com.t3.client.ui.token.TransferProgressDialog;
import com.t3.client.ui.zone.ZoneRenderer;
import com.t3.guid.GUID;
import com.t3.image.ImageUtil;
import com.t3.language.I18N;
import com.t3.model.Asset;
import com.t3.model.AssetManager;
import com.t3.model.CellPoint;
import com.t3.model.ExposedAreaMetaData;
import com.t3.model.LookupTable;
import com.t3.model.Player;
import com.t3.model.Token;
import com.t3.model.Zone;
import com.t3.model.Zone.Layer;
import com.t3.model.Zone.VisionType;
import com.t3.model.ZoneFactory;
import com.t3.model.ZonePoint;
import com.t3.model.campaign.Campaign;
import com.t3.model.campaign.CampaignFactory;
import com.t3.model.campaign.CampaignProperties;
import com.t3.model.chat.TextMessage;
import com.t3.model.drawing.DrawableTexturePaint;
import com.t3.model.grid.Grid;
import com.t3.networking.ServerConfig;
import com.t3.networking.ServerDisconnectHandler;
import com.t3.networking.ServerPolicy;
import com.t3.persistence.FileUtil;
import com.t3.persistence.PersistenceUtil;
import com.t3.persistence.PersistenceUtil.PersistedCampaign;
import com.t3.persistence.PersistenceUtil.PersistedMap;
import com.t3.util.ImageManager;
import com.t3.util.SysInfo;
import com.t3.util.UPnPUtil;

/**
 * This class acts as a container for a wide variety of {@link Action}s that are
 * used throughout the application. Most of these are added to the main frame
 * menu, but some are added dynamically as needed, sometimes to the frame menu
 * but also to the context menu (the "right-click menu").
 * 
 * Each object instantiated from {@link DefaultClientAction} should have an
 * initializer that calls {@link ClientAction#init(String)} and passes the base
 * message key from the properties file. This base message key will be used to
 * locate the text that should appear on the menu item as well as the
 * accelerator, mnemonic, and short description strings. (See the {@link I18N}
 * class for more details on how the key is used.
 * 
 * In addition, each object should override {@link ClientAction#isAvailable()}
 * and return true if the application is in a state where the Action should be
 * enabled. (The default is <code>true</code>.)
 * 
 * Last is the {@link ClientAction#execute(ActionEvent)} method. It is passed
 * the {@link ActionEvent} object that triggered this Action as a parameter. It
 * should perform the necessary work to accomplish the effect of the Action.
 */
public class AppActions {
    private static final Logger log = Logger.getLogger(AppActions.class);

    private static Set<Token> tokenCopySet = null;
    public static final int menuShortcut = getMenuShortcutKeyMask();

    private static int getMenuShortcutKeyMask() {
        int key = Toolkit.getDefaultToolkit().getMenuShortcutKeyMask();
        String prop = System.getProperty("os.name", "unknown");
        if ("darwin".equalsIgnoreCase(prop)) {
            // TODO Should we install our own AWTKeyStroke class?  If we do it should only be if menu shortcut is CTRL...
            if (key == Event.CTRL_MASK)
                key = Event.META_MASK;
            /*
             * In order for OpenJDK to work on Mac OS X, the user must have the
             * X11 package installed unless they're running headless. However,
             * in order for the Command key to work, the X11 Preferences must be
             * set to "Enable the Meta Key" in X11 applications. Essentially, if
             * this option is turned on, the Command key (called Meta in X11)
             * will be intercepted by the X11 package and not sent to the
             * application. The next step for TabletopTool will be better integration
             * with the Mac desktop to eliminate the X11 menu altogether.
             */
        }
        return key;
    }

    public static final Action MRU_LIST = new DefaultClientAction() {
        {
            init("menu.recent");
        }

        @Override
        public boolean isAvailable() {
            return TabletopTool.isHostingServer() || TabletopTool.isPersonalServer();
        }

        @Override
        public void execute(ActionEvent ae) {
            // Do nothing
        }
    };

    public static final Action EXPORT_SCREENSHOT = new DefaultClientAction() {
        {
            init("action.exportScreenShotAs");
        }

        @Override
        public void execute(ActionEvent e) {
            try {
                ExportDialog d = TabletopTool.getCampaign().getExportDialog();
                d.setVisible(true);
                TabletopTool.getCampaign().setExportDialog(d);
            } catch (Exception ex) {
                TabletopTool.showError("Cannot create the ExportDialog object", ex);
            }
        }
    };

    public static final Action EXPORT_SCREENSHOT_LAST_LOCATION = new DefaultClientAction() {
        {
            init("action.exportScreenShot");
        }

        @Override
        public void execute(ActionEvent e) {
            ExportDialog d = TabletopTool.getCampaign().getExportDialog();
            if (d == null || d.getExportLocation() == null || d.getExportSettings() == null) {
                // Can't do a save.. so try "save as"
                EXPORT_SCREENSHOT.actionPerformed(e);
            } else {
                try {
                    d.screenCapture();
                } catch (Exception ex) {
                    TabletopTool.showError("msg.error.failedExportingImage", ex);
                }
            }
        }
    };

    public static final Action EXPORT_CAMPAIGN_REPO = new AdminClientAction() {

        {
            init("admin.exportCampaignRepo");
        }

        @Override
        public void execute(ActionEvent e) {

            JFileChooser chooser = TabletopTool.getFrame().getSaveFileChooser();

            // Get target location
            if (chooser.showSaveDialog(TabletopTool.getFrame()) != JFileChooser.APPROVE_OPTION) {
                return;
            }

            // Default extension
            File selectedFile = chooser.getSelectedFile();
            if (!selectedFile.getName().toUpperCase().endsWith(".ZIP")) {
                selectedFile = new File(selectedFile.getAbsolutePath() + ".zip");
            }

            if (selectedFile.exists()) {
                if (!TabletopTool.confirm("msg.confirm.fileExists")) {
                    return;
                }
            }

            // Create index
            Campaign campaign = TabletopTool.getCampaign();
            Set<Asset> assetSet = new HashSet<Asset>();
            for (Zone zone : campaign.getZones()) {

                for (MD5Key key : zone.getAllAssetIds()) {
                    assetSet.add(AssetManager.getAsset(key));
                }
            }

            // Export to temp location
            File tmpFile = new File(
                    AppUtil.getAppHome("tmp").getAbsolutePath() + "/" + System.currentTimeMillis() + ".export");

            try (ZipOutputStream out = new ZipOutputStream(new FileOutputStream(tmpFile))) {
                StringBuilder builder = new StringBuilder();
                for (Asset asset : assetSet) {

                    // Index it
                    builder.append(asset.getId()).append(" assets/").append(asset.getId()).append("\n");
                    // Save it
                    ZipEntry entry = new ZipEntry("assets/" + asset.getId().toString());
                    out.putNextEntry(entry);
                    out.write(asset.getImage());
                }

                // Handle the index
                ByteArrayOutputStream bout = new ByteArrayOutputStream();
                try (GZIPOutputStream gzout = new GZIPOutputStream(bout)) {
                    gzout.write(builder.toString().getBytes());
                }

                ZipEntry entry = new ZipEntry("index.gz");
                out.putNextEntry(entry);
                out.write(bout.toByteArray());
                out.closeEntry();

                out.close();

                // Move to new location
                File mvFile = new File(AppUtil.getAppHome("tmp").getAbsolutePath() + "/" + selectedFile.getName());
                if (selectedFile.exists()) {
                    FileUtil.copyFile(selectedFile, mvFile);
                    selectedFile.delete();
                }

                FileUtil.copyFile(tmpFile, selectedFile);
                tmpFile.delete();

                if (mvFile.exists()) {
                    mvFile.delete();
                }

            } catch (IOException ioe) {
                TabletopTool.showError(I18N.getString("msg.error.failedExportingCampaignRepo"), ioe);
                return;
            }

            TabletopTool.showInformation("msg.confirm.campaignExported");
        }
    };

    public static final Action UPDATE_CAMPAIGN_REPO = new DeveloperClientAction() {
        {
            init("admin.updateCampaignRepo");
        }

        /**
         * <p>
         * This action performs a repository update by comparing the assets in
         * the current campaign against all assets in all repositories and
         * uploading assets to one of the repositories and creating a
         * replacement <b>index.gz</b> which is also uploaded.
         * </p>
         * <p>
         * For the purposes of this action, only the FTP protocol is supported.
         * The primary reason for this has to do with the way images will be
         * uploaded. If HTTP were used and a single file sent, there would need
         * to be a script on the remote end that knew how to unpack the file
         * correctly. This cannot be assumed in the general case.
         * </p>
         * <p>
         * Using FTP, we can upload individual files to particular directories.
         * While this same approach could be used for HTTP, once again the user
         * would have to install some kind of upload script on the server side.
         * This again makes HTTP impractical and FTP more "user-friendly".
         * </p>
         * <p>
         * <b>Implementation.</b> This method first makes a list of all known
         * repositories from the campaign properties. They are presented to the
         * user who then selects one as the destination for new assets to be
         * uploaded. A list of assets currently used in the campaign is then
         * generated and compared against the index files of all repositories
         * from the previous list. Any new assets are aggregated and the user is
         * presented with a summary of the images to be uploaded, including file
         * size. The user enters FTP connection information and the upload
         * begins as a separate thread. (Perhaps the Transfer Window can be used
         * to keep track of the uploading process?)
         * </p>
         * <p>
         * <b>Optimizations.</b> At some point, creating the list of assets
         * could be spun into another thread, although there's probably not much
         * value there. Or the FTP information could be collected at the
         * beginning and as assets are checked they could immediately begin
         * uploading with the summary including all assets, even those already
         * uploaded.
         * </p>
         * <p>
         * My review of FTP client libraries brought me to <a href=
         * "http://www.javaworld.com/javaworld/jw-04-2003/jw-0404-ftp.html">
         * this extensive review of FTP libraries</a> If we're going to do much
         * more with FTP, <b>Globus GridFTP</b> looks good, but the library
         * itself is 2.7MB.
         * </p>
         */
        @Override
        public void execute(ActionEvent e) {
            /*
             * 1. Ask the user to select repositories which should be
             * considered. 2. Ask the user for FTP upload information.
             */
            UpdateRepoDialog urd;

            Campaign campaign = TabletopTool.getCampaign();
            CampaignProperties props = campaign.getCampaignProperties();

            urd = new UpdateRepoDialog(TabletopTool.getFrame(), props.getRemoteRepositoryList(),
                    TabletopTool.getCampaign().getExportDialog().getExportLocation());
            urd.pack();
            urd.setVisible(true);
            if (urd.getStatus() == JOptionPane.CANCEL_OPTION) {
                return;
            }
            TabletopTool.getCampaign().getExportDialog().setExportLocation(urd.getFTPLocation());

            /*
             * 3. Check all assets against the repository indices and build a
             * new list from those that are not found.
             */
            Map<MD5Key, Asset> missing = AssetManager.findAllAssetsNotInRepositories(urd.getSelectedRepositories());

            /*
             * 4. Give the user a summary and ask for permission to begin the
             * upload. I'm going to display a listbox and let the user click on
             * elements of the list in order to see a preview to the right. But
             * there's no plan to make it a CheckBoxList. (Wouldn't be _that_
             * tough, however.)
             */
            if (!TabletopTool.confirm(I18N.getText("msg.confirm.aboutToBeginFTP", missing.size() + 1)))
                return;

            /*
             * 5. Build the index as we go, but add the images to FTP to a queue
             * handled by another thread. Add a progress bar of some type or use
             * the Transfer Status window.
             */
            try {
                File topdir = urd.getDirectory();
                File dir = new File(urd.isCreateSubdir() ? getFormattedDate(null) : null);

                Map<String, String> repoEntries = new HashMap<String, String>(missing.size());
                FTPClient ftp = new FTPClient(urd.getHostname(), urd.getUsername(), urd.getPassword());

                // Enabling this means the upload begins immediately upon the first queued entry
                ftp.setEnabled(true);
                ProgressBarList pbl = new ProgressBarList(TabletopTool.getFrame(), ftp, missing.size() + 1);

                for (Map.Entry<MD5Key, Asset> entry : missing.entrySet()) {
                    String remote = entry.getKey().toString();
                    repoEntries.put(remote, dir == null ? remote : new File(dir, remote).getPath());
                    ftp.addToQueue(
                            new FTPTransferObject(Direction.FTP_PUT, entry.getValue().getImage(), dir, remote));
                }
                // We're done with "missing", so empty it now.
                missing.clear();

                // Handle the index
                ByteArrayOutputStream bout = new ByteArrayOutputStream();
                String saveTo = urd.getSaveToRepository();
                // When this runs our local 'repoindx' is updated.  If the FTP upload later fails,
                // it doesn't really matter much because the assets are already there.  However,
                // if our local cache is ever downloaded again, we'll "forget" that the assets are
                // on the server.  It sounds like it might be nice to have some way to resync
                // the local system with the FTP server.  But it's probably better to let the user
                // do it manually.
                byte[] index = AssetManager.updateRepositoryMap(saveTo, repoEntries);
                repoEntries.clear();
                try (GZIPOutputStream gzout = new GZIPOutputStream(bout)) {
                    gzout.write(index);
                }
                ftp.addToQueue(new FTPTransferObject(Direction.FTP_PUT, bout.toByteArray(), topdir, "index.gz"));
            } catch (IOException ioe) {
                TabletopTool.showError("msg.error.failedUpdatingCampaignRepo", ioe);
                return;
            }
        }

        private String getFormattedDate(Date d) {
            // Use today's date as the directory on the FTP server.  This doesn't affect players'
            // ability to download it and might help the user determine what was uploaded to
            // their site and why.  It can't hurt. :)
            SimpleDateFormat df = (SimpleDateFormat) DateFormat.getDateInstance();
            df.applyPattern("yyyy-MM-dd");
            return df.format(d == null ? new Date() : d);
        }
    };

    /**
     * This is the menu option that forces clients to display the GM's current
     * map.
     */
    public static final Action ENFORCE_ZONE = new ZoneAdminClientAction() {

        {
            init("action.enforceZone");
        }

        @Override
        public void execute(ActionEvent e) {
            ZoneRenderer renderer = TabletopTool.getFrame().getCurrentZoneRenderer();
            if (renderer == null) {
                return;
            }

            TabletopTool.serverCommand().enforceZone(renderer.getZone().getId());
        }
    };

    public static final Action RESTORE_DEFAULT_IMAGES = new DefaultClientAction() {

        {
            init("action.restoreDefaultImages");
        }

        @Override
        public void execute(ActionEvent e) {
            try {
                AppSetup.installDefaultTokens();

                // TODO: Remove this hardwiring
                File unzipDir = new File(AppConstants.UNZIP_DIR.getAbsolutePath() + File.separator + "Default");
                TabletopTool.getFrame().addAssetRoot(unzipDir);
                AssetManager.searchForImageReferences(unzipDir, AppConstants.IMAGE_FILE_FILTER);

            } catch (IOException ioe) {
                TabletopTool.showError("msg.error.failedAddingDefaultImages", ioe);
            }
        }
    };

    public static final Action ADD_DEFAULT_TABLES = new DefaultClientAction() {

        {
            init("action.addDefaultTables");
        }

        @Override
        public void execute(ActionEvent e) {
            try (InputStream in = AppActions.class.getClassLoader()
                    .getResourceAsStream("com/t3/client/defaultTables.mtprops")) {
                // Load the defaults
                CampaignProperties properties = PersistenceUtil.loadCampaignProperties(in);

                // Make sure the images have been installed
                // Just pick a table and spot check
                LookupTable lookupTable = properties.getLookupTableMap().values().iterator().next();
                if (!AssetManager.hasAsset(lookupTable.getTableImage())) {
                    AppSetup.installDefaultTokens();
                }

                TabletopTool.getCampaign().mergeCampaignProperties(properties);

                TabletopTool.getFrame().repaint();

            } catch (IOException ioe) {
                TabletopTool.showError("msg.error.failedAddingDefaultTables", ioe);
            }
        }
    };

    public static final Action RENAME_ZONE = new AdminClientAction() {

        {
            init("action.renameMap");
        }

        @Override
        public void execute(ActionEvent e) {

            Zone zone = TabletopTool.getFrame().getCurrentZoneRenderer().getZone();
            String msg = I18N.getText("msg.confirm.renameMap", zone.getName() != null ? zone.getName() : "");
            String name = JOptionPane.showInputDialog(TabletopTool.getFrame(), msg);
            if (name != null) {
                zone.setName(name);
                TabletopTool.serverCommand().renameZone(zone.getId(), name);
            }
        }
    };

    public static final Action SHOW_FULLSCREEN = new DefaultClientAction() {

        {
            init("action.fullscreen");
        }

        @Override
        public void execute(ActionEvent e) {

            if (TabletopTool.getFrame().isFullScreen()) {
                TabletopTool.getFrame().showWindowed();
            } else {
                TabletopTool.getFrame().showFullScreen();
            }
        }
    };

    public static final Action SHOW_CONNECTION_INFO = new DefaultClientAction() {
        {
            init("action.showConnectionInfo");
        }

        @Override
        public boolean isAvailable() {
            return super.isAvailable() && (TabletopTool.isPersonalServer() || TabletopTool.isHostingServer());
        }

        @Override
        public void execute(ActionEvent e) {

            if (TabletopTool.getServer() == null) {
                return;
            }

            ConnectionInfoDialog dialog = new ConnectionInfoDialog(TabletopTool.getServer());
            dialog.setVisible(true);
        }
    };

    public static final Action SHOW_PREFERENCES = new DefaultClientAction() {
        {
            init("action.preferences");
        }

        @Override
        public void execute(ActionEvent e) {

            // Probably don't have to create a new one each time
            PreferencesDialog dialog = new PreferencesDialog();
            dialog.setVisible(true);
        }
    };

    public static final Action SAVE_MESSAGE_HISTORY = new DefaultClientAction() {
        {
            init("action.saveMessageHistory");
        }

        @Override
        public void execute(ActionEvent e) {
            JFileChooser chooser = TabletopTool.getFrame().getSaveFileChooser();
            chooser.setDialogTitle(I18N.getText("msg.title.saveMessageHistory"));
            chooser.setFileSelectionMode(JFileChooser.FILES_ONLY);

            if (chooser.showSaveDialog(TabletopTool.getFrame()) != JFileChooser.APPROVE_OPTION) {
                return;
            }
            File saveFile = chooser.getSelectedFile();
            if (!saveFile.getName().contains(".")) {
                saveFile = new File(saveFile.getAbsolutePath() + ".html");
            }
            if (saveFile.exists() && !TabletopTool.confirm("msg.confirm.fileExists")) {
                return;
            }

            try {
                String messageHistory = TabletopTool.getFrame().getCommandPanel().getMessageHistory();
                FileUtils.writeByteArrayToFile(saveFile, messageHistory.getBytes());
            } catch (IOException ioe) {
                TabletopTool.showError(I18N.getString("msg.error.failedSavingMessageHistory"), ioe);
            }
        }
    };

    public static final DefaultClientAction UNDO_PER_MAP = new DefaultClientAction() {
        {
            init("action.undoDrawing");
            isAvailable(); // XXX FJE Is this even necessary?
        }

        @Override
        public void execute(ActionEvent e) {
            Zone z = TabletopTool.getFrame().getCurrentZoneRenderer().getZone();
            z.undoDrawable();
            isAvailable();
            REDO_PER_MAP.isAvailable(); // XXX FJE Calling these forces the update, but won't the framework call them?
        }

        @Override
        public boolean isAvailable() {
            boolean result = false;
            T3Frame mtf = TabletopTool.getFrame();
            if (mtf != null) {
                ZoneRenderer zr = mtf.getCurrentZoneRenderer();
                if (zr != null) {
                    Zone z = zr.getZone();
                    result = z.canUndo();
                }
            }
            setEnabled(result);
            return isEnabled();
        }
    };

    public static final DefaultClientAction REDO_PER_MAP = new DefaultClientAction() {
        {
            init("action.redoDrawing");
            isAvailable(); // XXX Is this even necessary?
        }

        @Override
        public void execute(ActionEvent e) {
            Zone z = TabletopTool.getFrame().getCurrentZoneRenderer().getZone();
            z.redoDrawable();
            isAvailable();
            UNDO_PER_MAP.isAvailable();
        }

        @Override
        public boolean isAvailable() {
            boolean result = false;
            T3Frame mtf = TabletopTool.getFrame();
            if (mtf != null) {
                ZoneRenderer zr = mtf.getCurrentZoneRenderer();
                if (zr != null) {
                    Zone z = zr.getZone();
                    result = z.canRedo();
                }
            }
            setEnabled(result);
            return isEnabled();
        }
    };

    /*
     * public static final DefaultClientAction UNDO_DRAWING = new
     * DefaultClientAction() { { init("action.undoDrawing"); isAvailable(); //
     * XXX FJE Is this even necessary? }
     * 
     * @Override public void execute(ActionEvent e) {
     * DrawableUndoManager.getInstance().undo(); isAvailable();
     * REDO_DRAWING.isAvailable(); // XXX FJE Calling these forces the update,
     * but won't the framework call them? }
     * 
     * @Override public boolean isAvailable() {
     * setEnabled(DrawableUndoManager.getInstance().getUndoManager().canUndo());
     * return isEnabled(); } };
     * 
     * public static final DefaultClientAction REDO_DRAWING = new
     * DefaultClientAction() { { init("action.redoDrawing"); isAvailable(); //
     * XXX Is this even necessary? }
     * 
     * @Override public void execute(ActionEvent e) {
     * DrawableUndoManager.getInstance().redo(); isAvailable();
     * UNDO_DRAWING.isAvailable(); }
     * 
     * @Override public boolean isAvailable() {
     * setEnabled(DrawableUndoManager.getInstance().getUndoManager().canRedo());
     * return isEnabled(); } };
     */

    public static final DefaultClientAction CLEAR_DRAWING = new DefaultClientAction() {
        {
            init("action.clearDrawing");
        }

        @Override
        public void execute(ActionEvent e) {
            ZoneRenderer renderer = TabletopTool.getFrame().getCurrentZoneRenderer();
            if (renderer == null) {
                return;
            }
            Zone.Layer layer = renderer.getActiveLayer();
            if (!TabletopTool.confirm("msg.confirm.clearAllDrawings", layer)) {
                return;
            }
            // LATER: Integrate this with the undo stuff
            // FJE ServerMethodHandler.clearAllDrawings() now empties the DrawableUndoManager as well.
            TabletopTool.serverCommand().clearAllDrawings(renderer.getZone().getId(), layer);
        }

        @Override
        public boolean isAvailable() {
            return true;
        }
    };

    public static final DefaultClientAction CUT_TOKENS = new DefaultClientAction() {
        {
            init("action.cutTokens");
        }

        @Override
        public boolean isAvailable() {
            return super.isAvailable() && TabletopTool.getFrame().getCurrentZoneRenderer() != null;
        }

        @Override
        public void execute(ActionEvent e) {
            ZoneRenderer renderer = TabletopTool.getFrame().getCurrentZoneRenderer();
            Set<GUID> selectedSet = renderer.getSelectedTokenSet();
            cutTokens(renderer.getZone(), selectedSet);
        }
    };

    /**
     * Cut tokens in the set from the given zone.
     * <p>
     * If no tokens are deleted (because the incoming set is empty, because none
     * of the tokens in the set exist in the zone, or because the user doesn't
     * have permission to delete the tokens) then the
     * {@link TabletopTool#SND_INVALID_OPERATION} sound is played.
     * <p>
     * If any tokens<i>are</i> deleted, then the selection set for the zone is
     * cleared.
     * 
     * @param zone
     * @param tokenSet
     */
    public static final void cutTokens(Zone zone, Set<GUID> tokenSet) {
        // Only cut if some tokens are selected.  Don't want to accidentally
        // lose what might already be in the clipboard.
        boolean anythingDeleted = false;
        if (!tokenSet.isEmpty()) {
            copyTokens(tokenSet);

            // delete tokens
            for (GUID tokenGUID : tokenSet) {
                Token token = zone.getToken(tokenGUID);
                if (AppUtil.playerOwns(token)) {
                    anythingDeleted = true;
                    zone.removeToken(tokenGUID);
                    TabletopTool.serverCommand().removeToken(zone.getId(), tokenGUID);
                }
            }
        }
        if (anythingDeleted) {
            TabletopTool.getFrame().getCurrentZoneRenderer().clearSelectedTokens();
        } else {
            TabletopTool.playSound(TabletopTool.SND_INVALID_OPERATION);
        }
    }

    public static final DefaultClientAction COPY_TOKENS = new DefaultClientAction() {
        {
            init("action.copyTokens");
        }

        @Override
        public boolean isAvailable() {
            return super.isAvailable() && TabletopTool.getFrame().getCurrentZoneRenderer() != null;
        }

        @Override
        public void execute(ActionEvent e) {
            ZoneRenderer renderer = TabletopTool.getFrame().getCurrentZoneRenderer();
            copyTokens(renderer.getSelectedTokenSet());
        }
    };

    /**
     * Copies the given set of tokens to a holding area (not really the
     * "clipboard") so that they can be pasted back in again later. This is the
     * highest level function in that it determines token ownership (only owners
     * can copy/cut tokens).
     * 
     * @param tokenSet
     *            the set of tokens to copy; if empty, plays the
     *            {@link TabletopTool#SND_INVALID_OPERATION} sound.
     */
    public static final void copyTokens(Set<GUID> tokenSet) {
        List<Token> tokenList = null;
        boolean anythingCopied = false;
        if (!tokenSet.isEmpty()) {
            ZoneRenderer renderer = TabletopTool.getFrame().getCurrentZoneRenderer();
            Zone zone = renderer.getZone();
            tokenCopySet = new HashSet<Token>();
            tokenList = new ArrayList<Token>();

            for (GUID guid : tokenSet) {
                Token token = zone.getToken(guid);
                if (token != null && AppUtil.playerOwns(token)) {
                    anythingCopied = true;
                    tokenList.add(token);
                }
            }
        }
        // Only cut if some tokens are selected.  Don't want to accidentally
        // lose what might already be in the clipboard.
        if (anythingCopied) {
            copyTokens(tokenList);
        } else {
            TabletopTool.playSound(TabletopTool.SND_INVALID_OPERATION);
        }
    }

    private static Grid gridCopiedFrom = null;

    /**
     * Copies the given set of tokens to a holding area (not really the
     * "clipboard") so that they can be pasted back in again later. This method
     * ignores token ownership and operates on the entire list. A token's (x,y)
     * offset from the first token in the set is preserved so that relative
     * positions are preserved when they are pasted back in later.
     * <p>
     * Here are the criteria for how copy/paste of tokens should work:
     * <ol>
     * <li><b>Both maps are gridless.</b><br>
     * This case is very simple since there's no need to convert anything to
     * cell coordinates and back again.
     * <ul>
     * <li>All tokens have their relative pixel offsets saved and reproduced
     * when pasted back in.
     * </ul>
     * 
     * <li><b>Both maps have grids.</b><br>
     * This scheme will preserve proper spacing on the Token layer (for tokens)
     * and on the Object and Background layers (for stamps). The spacing will
     * NOT be correct when there's a mix of snapToGrid tokens and non-snapToGrid
     * tokens, but I don't see any way to correct that. (Well, we could
     * calculate a percentage distance from the token in the extreme corners of
     * the pasted set and use that percentage to calculate a pixel location.
     * Seems like a lot of work for not much payoff.)
     * <ul>
     * <li>For all tokens that are snapToGrid, the relative distances between
     * tokens should be kept in "cell" units when copied. That way they can be
     * pasted back in with the relative cell spacing reproduced.
     * <li>For all tokens that are not snapToGrid, their relative pixel offsets
     * should be saved and reproduced when the tokens are pasted.
     * </ul>
     * 
     * <li><b>The source map is gridless and the destination has a grid.</b><br>
     * This one is essentially identical to the first case.
     * <ul>
     * <li>All tokens are copied with relative pixel offsets. When pasted, those
     * relative offsets are used for all non-snapToGrid tokens, but snapToGrid
     * tokens have the relative pixel offsets applied and then are "snapped"
     * into the correct cell location.
     * </ul>
     * 
     * <li><b>The source map has a grid and the destination is gridless.</b><br>
     * This one is essentially identical to the first case.
     * <ul>
     * <li>All tokens have their relative pixel distances saved and those
     * offsets are reproduced when pasted.
     * </ul>
     * </ol>
     * 
     * @param tokenList
     *            the list of tokens to copy; if empty, plays the
     *            {@link TabletopTool#SND_INVALID_OPERATION} sound.
     */
    public static final void copyTokens(List<Token> tokenList) {
        // Only cut if some tokens are selected.  Don't want to accidentally
        // lose what might already be in the clipboard.
        if (!tokenList.isEmpty()) {
            Token topLeft = tokenList.get(0);
            tokenCopySet = new HashSet<Token>();
            for (Token originalToken : tokenList) {
                if (originalToken.getY() < topLeft.getY() || originalToken.getX() < topLeft.getX()) {
                    topLeft = originalToken;
                }
                Token newToken = new Token(originalToken);
                tokenCopySet.add(newToken);
            }
            /*
             * Normalize. For gridless maps, keep relative pixel distances. For
             * gridded maps, keep relative cell spacing. Since we're going to
             * keep relative positions, we can just modify the (x,y) coordinates
             * of all tokens by subtracting the position of the one in
             * 'topLeft'. On paste we can use the saved 'gridCopiedFrom' to
             * determine whether to use pixel distances or convert to cell
             * distances.
             */
            Zone zone = TabletopTool.getFrame().getCurrentZoneRenderer().getZone();
            try {
                gridCopiedFrom = (Grid) zone.getGrid().clone();
            } catch (CloneNotSupportedException e) {
                TabletopTool.showError("This can't happen as all grids MUST implement Cloneable!", e);
            }
            int x = topLeft.getX();
            int y = topLeft.getY();
            for (Token token : tokenCopySet) {
                // Save all token locations as relative pixel offsets.  They'll be made absolute when pasting them back in.
                token.setX(token.getX() - x);
                token.setY(token.getY() - y);
            }
        } else {
            TabletopTool.playSound(TabletopTool.SND_INVALID_OPERATION);
        }
    }

    public static final DefaultClientAction PASTE_TOKENS = new DefaultClientAction() {
        {
            init("action.pasteTokens");
        }

        @Override
        public boolean isAvailable() {
            return super.isAvailable() && tokenCopySet != null;
        }

        @Override
        public void execute(ActionEvent e) {
            ZoneRenderer renderer = TabletopTool.getFrame().getCurrentZoneRenderer();
            if (renderer == null) {
                return;
            }
            ScreenPoint screenPoint = renderer.getPointUnderMouse();
            if (screenPoint == null) {
                // Pick the middle of the map
                screenPoint = ScreenPoint.fromZonePoint(renderer, renderer.getCenterPoint());
            }
            ZonePoint zonePoint = screenPoint.convertToZone(renderer);
            pasteTokens(zonePoint, renderer.getActiveLayer());
            renderer.repaint();
        }
    };

    /**
     * Pastes tokens from {@link #tokenCopySet} into the current zone at the
     * specified location on the given layer. See {@link #copyTokens(List)} for
     * details of how the copy/paste operations work with respect to grid type
     * on the source and destination zones.
     * 
     * @param destination
     *            ZonePoint specifying where to paste; normally this is
     *            unchanged from the MouseEvent
     * @param layer
     *            the Zone.Layer that specifies which layer to paste onto
     */
    private static void pasteTokens(ZonePoint destination, Layer layer) {
        Zone zone = TabletopTool.getFrame().getCurrentZoneRenderer().getZone();
        Grid grid = zone.getGrid();

        boolean snapToGrid = false;
        Token topLeft = null;

        for (Token origToken : tokenCopySet) {
            if (topLeft == null || origToken.getY() < topLeft.getY() || origToken.getX() < topLeft.getX()) {
                topLeft = origToken;
            }
            snapToGrid |= origToken.isSnapToGrid();
        }
        boolean newZoneSupportsSnapToGrid = grid.getCapabilities().isSnapToGridSupported();
        boolean gridCopiedFromSupportsSnapToGrid = gridCopiedFrom.getCapabilities().isSnapToGridSupported();
        if (snapToGrid && newZoneSupportsSnapToGrid) {
            CellPoint cellPoint = grid.convert(destination);
            destination = grid.convert(cellPoint);
        }
        // Create a set of all tokenExposedAreaGUID's to make searching by GUID much faster.
        Set<GUID> allTokensSet = null;
        {
            List<Token> allTokensList = zone.getTokens();
            if (!allTokensList.isEmpty()) {
                allTokensSet = new HashSet<GUID>(allTokensList.size());
                for (Token token : allTokensList) {
                    allTokensSet.add(token.getExposedAreaGUID());
                }
            }
        }
        List<Token> tokenList = new ArrayList<Token>(tokenCopySet);
        Collections.sort(tokenList, Token.COMPARE_BY_ZORDER);
        List<String> failedPaste = new ArrayList<String>(tokenList.size());

        for (Token origToken : tokenList) {
            Token token = new Token(origToken);

            // need this here to get around times when a token is copied and pasted into the 
            // same zone, such as a framework "template"
            if (allTokensSet != null && allTokensSet.contains(token.getExposedAreaGUID())) {
                GUID guid = new GUID();
                token.setExposedAreaGUID(guid);
                ExposedAreaMetaData meta = zone.getExposedAreaMetaData(guid);
                // 'meta' references the object already stored in the zone's HashMap (it was created if necessary).
                meta.addToExposedAreaHistory(meta.getExposedAreaHistory());
                TabletopTool.serverCommand().updateExposedAreaMeta(zone.getId(), token.getExposedAreaGUID(), meta);
            }
            if (newZoneSupportsSnapToGrid && gridCopiedFromSupportsSnapToGrid && token.isSnapToGrid()) {
                // Convert (x,y) offset to a cell offset using the grid from the zone where the tokens were copied from
                CellPoint cp = gridCopiedFrom.convert(new ZonePoint(token.getX(), token.getY()));
                ZonePoint zp = grid.convert(cp);
                token.setX(zp.x + destination.x);
                token.setY(zp.y + destination.y);
            } else {
                // For gridless sources, gridless destinations, or tokens that are not SnapToGrid:  just use the pixel offsets
                token.setX(token.getX() + destination.x);
                token.setY(token.getY() + destination.y);
            }
            // paste into correct layer
            token.setLayer(layer);

            // check the token's name and change it, if necessary
            // XXX Merge this with the drag/drop code in ZoneRenderer.addTokens().
            boolean tokenNeedsNewName = false;
            if (TabletopTool.getPlayer().isGM()) {
                // For GMs, only change the name of NPCs.  It's possible that we should be changing the name of PCs as well
                // since macros don't work properly when multiple tokens have the same name, but if we changed it without
                // asking it could be seriously confusing.  Yet we don't want to popup a confirmation every time the GM pastes either. :(
                tokenNeedsNewName = token.getType() != Token.Type.PC;
            } else {
                // For Players, check to see if the name is already in use.  If it is already in use, make sure the current Player
                // owns the token being duplicated (to avoid subtle ways of manipulating someone else's token!).
                Token tokenNameUsed = zone.getTokenByName(token.getName());
                if (tokenNameUsed != null) {
                    if (!AppUtil.playerOwns(tokenNameUsed)) {
                        failedPaste.add(token.getName());
                        continue;
                    }
                    tokenNeedsNewName = true;
                }
            }
            if (tokenNeedsNewName) {
                String newName = T3Util.nextTokenId(zone, token, true);
                token.setName(newName);
            }
            zone.putToken(token);
            TabletopTool.serverCommand().putToken(zone.getId(), token);
        }
        if (!failedPaste.isEmpty()) {
            String mesg = "Failed to paste token(s) with duplicate name(s): " + failedPaste;
            TextMessage msg = TextMessage.gm(mesg);
            TabletopTool.addMessage(msg);
            //         msg.setChannel(Channel.ME);
            //         TabletopTool.addMessage(msg);
        }
    }

    public static final Action REMOVE_ASSET_ROOT = new DefaultClientAction() {
        {
            init("action.removeAssetRoot");
        }

        @Override
        public void execute(ActionEvent e) {
            AssetPanel assetPanel = TabletopTool.getFrame().getAssetPanel();
            Directory dir = assetPanel.getSelectedAssetRoot();

            if (dir == null) {
                TabletopTool.showError("msg.error.mustSelectAssetGroupFirst");
                return;
            }
            if (!assetPanel.isAssetRoot(dir)) {
                TabletopTool.showError("msg.error.mustSelectRootGroup");
                return;
            }
            AppPreferences.removeAssetRoot(dir.getPath());
            assetPanel.removeAssetRoot(dir);
        }
    };

    public static final Action BOOT_CONNECTED_PLAYER = new DefaultClientAction() {
        {
            init("action.bootConnectedPlayer");
        }

        @Override
        public boolean isAvailable() {
            return TabletopTool.isHostingServer() || TabletopTool.getPlayer().isGM();
        }

        @Override
        public void execute(ActionEvent e) {
            ClientConnectionPanel panel = TabletopTool.getFrame().getConnectionPanel();
            Player selectedPlayer = panel.getSelectedValue();

            if (selectedPlayer == null) {
                TabletopTool.showError("msg.error.mustSelectPlayerFirst");
                return;
            }
            if (TabletopTool.getPlayer().equals(selectedPlayer)) {
                TabletopTool.showError("msg.error.cantBootSelf");
                return;
            }
            if (TabletopTool.isPlayerConnected(selectedPlayer.getName())) {
                String msg = I18N.getText("msg.confirm.bootPlayer", selectedPlayer.getName());
                if (TabletopTool.confirm(msg)) {
                    TabletopTool.serverCommand().bootPlayer(selectedPlayer.getName());
                    msg = I18N.getText("msg.info.playerBooted", selectedPlayer.getName());
                    TabletopTool.showInformation(msg);
                    return;
                }
            }
            TabletopTool.showError("msg.error.failedToBoot");
        }
    };

    /**
     * This is the menu item that lets the GM override the typing notification
     * toggle on the clients
     */
    public static final Action TOGGLE_ENFORCE_NOTIFICATION = new AdminClientAction() {
        {
            init("action.enforceNotification");
        }

        @Override
        public boolean isSelected() {
            return AppState.isNotificationEnforced();
        }

        @Override
        public void execute(ActionEvent e) {
            AppState.setNotificationEnforced(!AppState.isNotificationEnforced());
            TabletopTool.serverCommand().enforceNotification(AppState.isNotificationEnforced());
        }

    };

    /**
     * This is the menu option that forces the player view to continuously track
     * the GM view.
     */
    public static final Action TOGGLE_LINK_PLAYER_VIEW = new AdminClientAction() {
        {
            init("action.linkPlayerView");
        }

        @Override
        public boolean isSelected() {
            return AppState.isPlayerViewLinked();
        }

        @Override
        public void execute(ActionEvent e) {

            AppState.setPlayerViewLinked(!AppState.isPlayerViewLinked());
            TabletopTool.getFrame().getCurrentZoneRenderer().maybeForcePlayersView();
        }

    };

    public static final Action TOGGLE_SHOW_PLAYER_VIEW = new AdminClientAction() {
        {
            init("action.showPlayerView");
        }

        @Override
        public boolean isSelected() {
            return AppState.isShowAsPlayer();
        }

        @Override
        public void execute(ActionEvent e) {

            AppState.setShowAsPlayer(!AppState.isShowAsPlayer());
            TabletopTool.getFrame().refresh();
        }

    };

    public static final Action TOGGLE_SHOW_LIGHT_SOURCES = new AdminClientAction() {
        {
            init("action.showLightSources");
        }

        @Override
        public boolean isSelected() {
            return AppState.isShowLightSources();
        }

        @Override
        public void execute(ActionEvent e) {

            AppState.setShowLightSources(!AppState.isShowLightSources());
            TabletopTool.getFrame().refresh();
        }

    };

    public static final Action TOGGLE_COLLECT_PROFILING_DATA = new DefaultClientAction() {
        {
            init("action.collectPerformanceData");
        }

        @Override
        public boolean isSelected() {
            return AppState.isCollectProfilingData();
        }

        @Override
        public void execute(ActionEvent e) {
            AppState.setCollectProfilingData(!AppState.isCollectProfilingData());
            TabletopTool.getProfilingNoteFrame().setVisible(AppState.isCollectProfilingData());
        }
    };

    public static final Action TOGGLE_SHOW_MOVEMENT_MEASUREMENTS = new DefaultClientAction() {
        {
            init("action.showMovementMeasures");
        }

        @Override
        public boolean isSelected() {
            return AppState.getShowMovementMeasurements();
        }

        @Override
        public void execute(ActionEvent e) {

            AppState.setShowMovementMeasurements(!AppState.getShowMovementMeasurements());
            if (TabletopTool.getFrame().getCurrentZoneRenderer() != null) {
                TabletopTool.getFrame().getCurrentZoneRenderer().repaint();
            }
        }

    };

    public static final Action COPY_ZONE = new ZoneAdminClientAction() {
        {
            init("action.copyZone");
        }

        @Override
        public void execute(ActionEvent e) {
            Zone zone = TabletopTool.getFrame().getCurrentZoneRenderer().getZone();
            // XXX Perhaps ask the user if the copied map should have its GEA and/or TEA cleared?  An imported map would ask...
            String zoneName = JOptionPane.showInputDialog("New map name:", "Copy of " + zone.getName());
            if (zoneName != null) {
                Zone zoneCopy = new Zone(zone);
                zoneCopy.setName(zoneName);
                TabletopTool.addZone(zoneCopy);
            }
        }
    };

    public static final Action REMOVE_ZONE = new ZoneAdminClientAction() {
        {
            init("action.removeZone");
        }

        @Override
        public void execute(ActionEvent e) {
            if (!TabletopTool.confirm("msg.confirm.removeZone")) {
                return;
            }
            ZoneRenderer renderer = TabletopTool.getFrame().getCurrentZoneRenderer();
            TabletopTool.removeZone(renderer.getZone());
        }
    };

    public static final Action SHOW_ABOUT = new DefaultClientAction() {
        {
            init("action.showAboutDialog");
        }

        @Override
        public void execute(ActionEvent e) {
            TabletopTool.getFrame().showAboutDialog();
        }
    };

    /**
     * This is the menu option that warps all clients views to the current GM's
     * view.
     */
    public static final Action ENFORCE_ZONE_VIEW = new ZoneAdminClientAction() {
        {
            init("action.enforceView");
        }

        @Override
        public void execute(ActionEvent e) {
            ZoneRenderer renderer = TabletopTool.getFrame().getCurrentZoneRenderer();
            if (renderer == null) {
                return;
            }
            renderer.forcePlayersView();
        }
    };

    /**
     * Start entering text into the chat field
     */
    public static final String CHAT_COMMAND_ID = "action.sendChat";

    public static final Action CHAT_COMMAND = new DefaultClientAction() {
        {
            init(CHAT_COMMAND_ID);
        }

        @Override
        public void execute(ActionEvent e) {
            if (!TabletopTool.getFrame().isCommandPanelVisible()) {
                TabletopTool.getFrame().showCommandPanel();
                TabletopTool.getFrame().getCommandPanel().startChat();
            } else {
                TabletopTool.getFrame().hideCommandPanel();
            }
        }
    };

    public static final String COMMAND_UP_ID = "action.commandUp";

    public static final String COMMAND_DOWN_ID = "action.commandDown";

    /**
     * Start entering text into the chat field
     */
    public static final String ENTER_COMMAND_ID = "action.runMacro";

    public static final Action ENTER_COMMAND = new DefaultClientAction() {
        {
            init(ENTER_COMMAND_ID, false);
        }

        @Override
        public void execute(ActionEvent e) {
            TabletopTool.getFrame().getCommandPanel().startMacro();
        }
    };

    /**
     * Action tied to the chat field to commit the command.
     */
    public static final String COMMIT_COMMAND_ID = "action.commitCommand";

    public static final Action COMMIT_COMMAND = new DefaultClientAction() {
        {
            init(COMMIT_COMMAND_ID);
        }

        @Override
        public void execute(ActionEvent e) {
            TabletopTool.getFrame().getCommandPanel().commitCommand();
        }
    };

    /**
     * Action tied to the chat field to commit the command.
     */
    public static final String CANCEL_COMMAND_ID = "action.cancelCommand";

    public static final Action CANCEL_COMMAND = new DefaultClientAction() {
        {
            init(CANCEL_COMMAND_ID);
        }

        @Override
        public void execute(ActionEvent e) {
            TabletopTool.getFrame().getCommandPanel().cancelCommand();
        }
    };

    public static final Action ADJUST_GRID = new ZoneAdminClientAction() {
        {
            init("action.adjustGrid");
        }

        @Override
        public void execute(ActionEvent e) {

            TabletopTool.getFrame().getToolbox().setSelectedTool(GridTool.class);
        }

    };

    public static final Action ADJUST_BOARD = new ZoneAdminClientAction() {
        {
            init("action.adjustBoard");
        }

        @Override
        public void execute(ActionEvent e) {

            if (TabletopTool.getFrame().getCurrentZoneRenderer().getZone().getMapAssetId() != null) {
                TabletopTool.getFrame().getToolbox().setSelectedTool(BoardTool.class);
            } else {
                TabletopTool.showInformation(I18N.getText("action.error.noMapBoard"));
            }
        }

    };

    private static TransferProgressDialog transferProgressDialog;
    public static final Action SHOW_TRANSFER_WINDOW = new DefaultClientAction() {
        {
            init("msg.info.showTransferWindow");
        }

        @Override
        public void execute(ActionEvent e) {

            if (transferProgressDialog == null) {
                transferProgressDialog = new TransferProgressDialog();
            }

            if (transferProgressDialog.isShowing()) {
                return;
            }

            transferProgressDialog.showDialog();
        }

    };

    public static final Action TOGGLE_GRID = new DefaultClientAction() {
        {
            init("action.showGrid");
            putValue(Action.SHORT_DESCRIPTION, getValue(Action.NAME));
            try {
                putValue(Action.SMALL_ICON, new ImageIcon(ImageUtil.getImage("com/t3/client/image/grid.gif")));
            } catch (IOException ioe) {
                TabletopTool.showError("While retrieving built-in 'grid.gif' image", ioe);
            }
        }

        @Override
        public boolean isSelected() {
            return AppState.isShowGrid();
        }

        @Override
        public void execute(ActionEvent e) {
            AppState.setShowGrid(!AppState.isShowGrid());
            if (TabletopTool.getFrame().getCurrentZoneRenderer() != null) {
                TabletopTool.getFrame().getCurrentZoneRenderer().repaint();
            }
        }
    };

    public static final Action TOGGLE_COORDINATES = new DefaultClientAction() {
        {
            init("action.showCoordinates");
            putValue(Action.SHORT_DESCRIPTION, getValue(Action.NAME));
        }

        @Override
        public boolean isAvailable() {
            ZoneRenderer renderer = TabletopTool.getFrame().getCurrentZoneRenderer();
            return renderer != null && renderer.getZone().getGrid().getCapabilities().isCoordinatesSupported();
        }

        @Override
        public boolean isSelected() {
            return AppState.isShowCoordinates();
        }

        @Override
        public void execute(ActionEvent e) {

            AppState.setShowCoordinates(!AppState.isShowCoordinates());

            TabletopTool.getFrame().getCurrentZoneRenderer().repaint();
        }
    };

    public static final Action TOGGLE_ZOOM_LOCK = new DefaultClientAction() {
        {
            init("action.zoomLock");
            putValue(Action.SHORT_DESCRIPTION, getValue(Action.NAME));
        }

        @Override
        public boolean isAvailable() {
            ZoneRenderer renderer = TabletopTool.getFrame().getCurrentZoneRenderer();
            return renderer != null;
        }

        @Override
        public boolean isSelected() {
            return AppState.isZoomLocked();
        }

        @Override
        public void execute(ActionEvent e) {
            AppState.setZoomLocked(!AppState.isZoomLocked());
            TabletopTool.getFrame().getZoomStatusBar().update(); // So the textfield becomes grayed out
        }
    };

    public static final Action TOGGLE_FOG = new ZoneAdminClientAction() {
        {
            init("action.enableFogOfWar");
        }

        @Override
        public boolean isSelected() {
            ZoneRenderer renderer = TabletopTool.getFrame().getCurrentZoneRenderer();
            if (renderer == null) {
                return false;
            }

            return renderer.getZone().hasFog();
        }

        @Override
        public void execute(ActionEvent e) {

            ZoneRenderer renderer = TabletopTool.getFrame().getCurrentZoneRenderer();
            if (renderer == null) {
                return;
            }

            Zone zone = renderer.getZone();
            zone.setHasFog(!zone.hasFog());

            TabletopTool.serverCommand().setZoneHasFoW(zone.getId(), zone.hasFog());

            renderer.repaint();
        }
    };

    public static class SetVisionType extends ZoneAdminClientAction {
        private final VisionType visionType;

        public SetVisionType(VisionType visionType) {
            this.visionType = visionType;
            init("visionType." + visionType.name());
        }

        @Override
        public boolean isSelected() {
            ZoneRenderer renderer = TabletopTool.getFrame().getCurrentZoneRenderer();
            if (renderer == null) {
                return false;
            }

            return renderer.getZone().getVisionType() == visionType;
        }

        @Override
        public void execute(ActionEvent e) {

            ZoneRenderer renderer = TabletopTool.getFrame().getCurrentZoneRenderer();
            if (renderer == null) {
                return;
            }

            Zone zone = renderer.getZone();

            if (zone.getVisionType() != visionType) {

                zone.setVisionType(visionType);

                TabletopTool.serverCommand().setVisionType(zone.getId(), visionType);

                renderer.flushFog();
                renderer.flushLight();
                renderer.repaint();
            }
        }
    };

    public static final Action TOGGLE_SHOW_TOKEN_NAMES = new DefaultClientAction() {
        {
            init("action.showNames");
            putValue(Action.SHORT_DESCRIPTION, getValue(Action.NAME));
            try {
                putValue(Action.SMALL_ICON, new ImageIcon(ImageUtil.getImage("com/t3/client/image/names.png")));
            } catch (IOException ioe) {
                TabletopTool.showError("While retrieving built-in 'names.png' image", ioe);
            }
        }

        @Override
        public void execute(ActionEvent e) {

            AppState.setShowTokenNames(!AppState.isShowTokenNames());
            if (TabletopTool.getFrame().getCurrentZoneRenderer() != null) {
                TabletopTool.getFrame().getCurrentZoneRenderer().repaint();
            }
        }
    };

    public static final Action TOGGLE_CURRENT_ZONE_VISIBILITY = new ZoneAdminClientAction() {

        {
            init("action.hideMap");
        }

        @Override
        public boolean isSelected() {
            ZoneRenderer renderer = TabletopTool.getFrame().getCurrentZoneRenderer();
            if (renderer == null) {
                return false;
            }
            return renderer.getZone().isVisible();
        }

        @Override
        public void execute(ActionEvent e) {

            ZoneRenderer renderer = TabletopTool.getFrame().getCurrentZoneRenderer();
            if (renderer == null) {
                return;
            }

            // TODO: consolidate this code with ZonePopupMenu
            Zone zone = renderer.getZone();
            zone.setVisible(!zone.isVisible());

            TabletopTool.serverCommand().setZoneVisibility(zone.getId(), zone.isVisible());
            TabletopTool.getFrame().repaint();
        }
    };

    public static final Action NEW_CAMPAIGN = new AdminClientAction() {
        {
            init("action.newCampaign");
        }

        @Override
        public void execute(ActionEvent e) {

            if (!TabletopTool.confirm("msg.confirm.newCampaign")) {

                return;
            }

            Campaign campaign = CampaignFactory.createBasicCampaign();
            AppState.setCampaignFile(null);
            TabletopTool.setCampaign(campaign);
            TabletopTool.serverCommand().setCampaign(campaign);

            ImageManager.flush();
            TabletopTool.getFrame()
                    .setCurrentZoneRenderer(TabletopTool.getFrame().getZoneRenderer(campaign.getZones().get(0)));
        }
    };

    /**
     * Note that the ZOOM actions are defined as DefaultClientAction types. This
     * allows the {@link ClientAction#getKeyStroke()} method to be invoked where
     * otherwise it couldn't be.
     * <p>
     * (Well, it <i>could be</i> if we cast this object to the right type
     * everywhere else but that's just tedious. And what is tedious is
     * error-prone. :))
     */
    public static final DefaultClientAction ZOOM_IN = new DefaultClientAction() {
        {
            init("action.zoomIn", false);
        }

        @Override
        public boolean isAvailable() {
            return !AppState.isZoomLocked();
        }

        @Override
        public void execute(ActionEvent e) {
            ZoneRenderer renderer = TabletopTool.getFrame().getCurrentZoneRenderer();
            if (renderer != null) {
                Dimension size = renderer.getSize();
                renderer.zoomIn(size.width / 2, size.height / 2);
                renderer.maybeForcePlayersView();
            }
        }
    };

    public static final DefaultClientAction ZOOM_OUT = new DefaultClientAction() {
        {
            init("action.zoomOut", false);
        }

        @Override
        public boolean isAvailable() {
            return !AppState.isZoomLocked();
        }

        @Override
        public void execute(ActionEvent e) {
            ZoneRenderer renderer = TabletopTool.getFrame().getCurrentZoneRenderer();
            if (renderer != null) {
                Dimension size = renderer.getSize();
                renderer.zoomOut(size.width / 2, size.height / 2);
                renderer.maybeForcePlayersView();
            }
        }
    };

    public static final DefaultClientAction ZOOM_RESET = new DefaultClientAction() {
        private Double lastZoom;

        {
            init("action.zoom100", false);
        }

        @Override
        public boolean isAvailable() {
            return !AppState.isZoomLocked();
        }

        @Override
        public void execute(ActionEvent e) {
            ZoneRenderer renderer = TabletopTool.getFrame().getCurrentZoneRenderer();
            if (renderer != null) {
                // Revert to last zoom if we have one, but don't if the user has manually
                // changed the scale since the last reset zoom (one to one index)
                if (lastZoom != null && renderer.getScale() == renderer.getZoneScale().getOneToOneScale()) {
                    // Go back to the previous zoom
                    renderer.setScale(lastZoom);

                    // But make sure the next time we'll go back to 1:1
                    lastZoom = null;
                } else {
                    lastZoom = renderer.getScale();
                    renderer.zoomReset(renderer.getWidth() / 2, renderer.getHeight() / 2);
                }
                renderer.maybeForcePlayersView();
            }
        }
    };

    public static final Action TOGGLE_MOVEMENT_LOCK = new AdminClientAction() {
        {
            init("action.toggleMovementLock");
        }

        @Override
        public boolean isSelected() {
            return TabletopTool.getServerPolicy().isMovementLocked();
        }

        @Override
        public void execute(ActionEvent e) {

            ServerPolicy policy = TabletopTool.getServerPolicy();
            policy.setIsMovementLocked(!policy.isMovementLocked());

            TabletopTool.updateServerPolicy(policy);
            TabletopTool.getServer().updateServerPolicy(policy);
        }
    };

    public static final Action START_SERVER = new ClientAction() {
        {
            init("action.serverStart");
        }

        @Override
        public boolean isAvailable() {
            return TabletopTool.isPersonalServer();
        }

        @Override
        public void execute(ActionEvent e) {
            runBackground(new Runnable() {
                @Override
                public void run() {
                    if (!TabletopTool.isPersonalServer()) {
                        TabletopTool.showError("msg.error.alreadyRunningServer");
                        return;
                    }

                    // TODO: Need to shut down the existing server first;
                    StartServerDialog dialog = new StartServerDialog();
                    dialog.showDialog();

                    if (!dialog.accepted()) // Results stored in Preferences.userRoot()
                        return;

                    StartServerDialogPreferences serverProps = new StartServerDialogPreferences(); // data retrieved from Preferences.userRoot()

                    ServerPolicy policy = new ServerPolicy();
                    policy.setAutoRevealOnMovement(serverProps.isAutoRevealOnMovement());
                    policy.setUseStrictTokenManagement(serverProps.getUseStrictTokenOwnership());
                    policy.setPlayersCanRevealVision(serverProps.getPlayersCanRevealVision());
                    policy.setUseIndividualViews(serverProps.getUseIndividualViews());
                    policy.setPlayersReceiveCampaignMacros(serverProps.getPlayersReceiveCampaignMacros());
                    policy.setIsMovementLocked(TabletopTool.getServerPolicy().isMovementLocked());

                    // Tool Tips for unformatted inline rolls.
                    policy.setUseToolTipsForDefaultRollFormat(serverProps.getUseToolTipsForUnformattedRolls());

                    //my addition
                    policy.setRestrictedImpersonation(serverProps.getRestrictedImpersonation());
                    policy.setMovementMetric(serverProps.getMovementMetric());
                    boolean useIF = serverProps.getUseIndividualViews() && serverProps.getUseIndividualFOW();
                    policy.setUseIndividualFOW(useIF);

                    ServerConfig config = new ServerConfig(serverProps.getUsername(), serverProps.getGMPassword(),
                            serverProps.getPlayerPassword(), serverProps.getPort(), serverProps.getT3Name());

                    // Use the existing campaign
                    Campaign campaign = TabletopTool.getCampaign();

                    boolean failed = false;
                    try {
                        ServerDisconnectHandler.disconnectExpected = true;
                        TabletopTool.stopServer();

                        // Use UPnP to open port in router
                        if (serverProps.getUseUPnP()) {
                            UPnPUtil.openPort(serverProps.getPort());
                        }
                        // Right now set this is set to whatever the last server settings were.  If we wanted to turn it on and
                        // leave it turned on, the line would change to:
                        //                  campaign.setHasUsedFogToolbar(useIF || campaign.hasUsedFogToolbar());
                        campaign.setHasUsedFogToolbar(useIF);

                        // Make a copy of the campaign since we don't coordinate local changes well ... yet

                        /*
                         * JFJ 2010-10-27 The below creates a NEW campaign with
                         * a copy of the existing campaign. However, this is NOT
                         * a full copy. In the constructor called below, each
                         * zone from the previous campaign(ie, the one passed
                         * in) is recreated. This means that only some items for
                         * that campaign, zone(s), and token's are copied over
                         * when you start a new server instance.
                         * 
                         * You need to modify either Campaign(Campaign) or
                         * Zone(Zone) to get any data you need to persist from
                         * the pre-server campaign to the post server start up
                         * campaign.
                         */
                        TabletopTool.startServer(dialog.getUsernameTextField().getText(), config, policy,
                                new Campaign(campaign));

                        // Connect to server
                        String playerType = dialog.getRoleCombo().getSelectedItem().toString();
                        if (playerType.equals("GM")) {
                            TabletopTool.createConnection("localhost", serverProps.getPort(),
                                    new Player(dialog.getUsernameTextField().getText(), serverProps.getRole(),
                                            serverProps.getGMPassword()));
                        } else {
                            TabletopTool.createConnection("localhost", serverProps.getPort(),
                                    new Player(dialog.getUsernameTextField().getText(), serverProps.getRole(),
                                            serverProps.getPlayerPassword()));
                        }

                        // connecting
                        TabletopTool.getFrame().getConnectionStatusPanel()
                                .setStatus(ConnectionStatusPanel.Status.server);
                        TabletopTool.addLocalMessage("<span style='color:blue'><i>"
                                + I18N.getText("msg.info.startServer") + "</i></span>");
                    } catch (UnknownHostException uh) {
                        TabletopTool.showError("msg.error.invalidLocalhost", uh);
                        failed = true;
                    } catch (IOException ioe) {
                        TabletopTool.showError("msg.error.failedConnect", ioe);
                        failed = true;
                    }

                    if (failed) {
                        try {
                            TabletopTool.startPersonalServer(campaign);
                        } catch (IOException ioe) {
                            TabletopTool.showError("msg.error.failedStartPersonalServer", ioe);
                        }
                    }

                }
            });
        }
    };

    public static final Action CONNECT_TO_SERVER = new ClientAction() {
        {
            init("action.clientConnect");
        }

        @Override
        public boolean isAvailable() {
            return TabletopTool.isPersonalServer();
        }

        @Override
        public void execute(ActionEvent e) {
            if (TabletopTool.isCampaignDirty() && !TabletopTool.confirm("msg.confirm.loseChanges"))
                return;

            final ConnectToServerDialog dialog = new ConnectToServerDialog();
            dialog.showDialog();
            if (!dialog.accepted())
                return;

            ServerDisconnectHandler.disconnectExpected = true;
            LOAD_MAP.setSeenWarning(false);
            TabletopTool.stopServer();

            // Install a temporary gimped campaign until we get the one from the
            // server
            final Campaign oldCampaign = TabletopTool.getCampaign();
            TabletopTool.setCampaign(new Campaign());

            // connecting
            TabletopTool.getFrame().getConnectionStatusPanel().setStatus(ConnectionStatusPanel.Status.connected);

            // Show the user something interesting until we've got the campaign
            // Look in ClientMethodHandler.setCampaign() for the corresponding
            // hideGlassPane
            StaticMessageDialog progressDialog = new StaticMessageDialog(I18N.getText("msg.info.connecting"));
            TabletopTool.getFrame().showFilledGlassPane(progressDialog);

            runBackground(new Runnable() {
                @Override
                public void run() {
                    boolean failed = false;
                    try {
                        ConnectToServerDialogPreferences prefs = new ConnectToServerDialogPreferences();
                        TabletopTool.createConnection(dialog.getServer(), dialog.getPort(),
                                new Player(prefs.getUsername(), prefs.getRole(), prefs.getPassword()));

                        TabletopTool.getFrame().hideGlassPane();
                        TabletopTool.getFrame().showFilledGlassPane(
                                new StaticMessageDialog(I18N.getText("msg.info.campaignLoading")));
                    } catch (UnknownHostException e1) {
                        TabletopTool.showError("msg.error.unknownHost", e1);
                        failed = true;
                    } catch (IOException e1) {
                        TabletopTool.showError("msg.error.failedLoadCampaign", e1);
                        failed = true;
                    }
                    if (failed || TabletopTool.getConnection() == null) {
                        TabletopTool.getFrame().hideGlassPane();
                        try {
                            TabletopTool.startPersonalServer(oldCampaign);
                        } catch (IOException ioe) {
                            TabletopTool.showError("msg.error.failedStartPersonalServer", ioe);
                        }
                    }

                }
            });
        }
    };

    public static final Action DISCONNECT_FROM_SERVER = new ClientAction() {
        {
            init("action.clientDisconnect");
        }

        @Override
        public boolean isAvailable() {
            return !TabletopTool.isPersonalServer();
        }

        @Override
        public void execute(ActionEvent e) {
            if (TabletopTool.isHostingServer() && !TabletopTool.confirm("msg.confirm.hostingDisconnect"))
                return;
            disconnectFromServer();
        }
    };

    public static void disconnectFromServer() {
        Campaign campaign = TabletopTool.isHostingServer() ? TabletopTool.getCampaign()
                : CampaignFactory.createBasicCampaign();
        ServerDisconnectHandler.disconnectExpected = true;
        LOAD_MAP.setSeenWarning(false);
        TabletopTool.stopServer();
        TabletopTool.disconnect();
        try {
            TabletopTool.startPersonalServer(campaign);
        } catch (IOException ioe) {
            TabletopTool.showError("msg.error.failedStartPersonalServer", ioe);
        }
    }

    public static final Action LOAD_CAMPAIGN = new DefaultClientAction() {
        {
            init("action.loadCampaign");
        }

        @Override
        public boolean isAvailable() {
            return TabletopTool.isHostingServer() || TabletopTool.isPersonalServer();
        }

        @Override
        public void execute(ActionEvent ae) {
            if (TabletopTool.isCampaignDirty() && !TabletopTool.confirm("msg.confirm.loseChanges"))
                return;
            JFileChooser chooser = new CampaignPreviewFileChooser();
            chooser.setDialogTitle(I18N.getText("msg.title.loadCampaign"));
            chooser.setFileSelectionMode(JFileChooser.FILES_ONLY);

            if (chooser.showOpenDialog(TabletopTool.getFrame()) == JFileChooser.APPROVE_OPTION) {
                File campaignFile = chooser.getSelectedFile();
                loadCampaign(campaignFile);
            }
        }
    };

    private static class CampaignPreviewFileChooser extends PreviewPanelFileChooser {
        private static final long serialVersionUID = -6566116259521360428L;

        CampaignPreviewFileChooser() {
            super();
            addChoosableFileFilter(TabletopTool.getFrame().getCmpgnFileFilter());
        }

        @Override
        protected File getImageFileOfSelectedFile() {
            if (getSelectedFile() == null) {
                return null;
            }
            return PersistenceUtil.getCampaignThumbnailFile(getSelectedFile().getName());
        }
    }

    public static void loadCampaign(final File campaignFile) {
        new Thread() {
            @Override
            public void run() {
                TabletopTool.getAutoSaveManager().pause(); // Pause auto-save while loading
                if (AppState.isSaving()) {
                    int count = 5;
                    do {
                        StaticMessageDialog progressDialog = new StaticMessageDialog(
                                "Waiting " + count + " seconds for save to finish...");
                        TabletopTool.getFrame().showFilledGlassPane(progressDialog);
                        try {
                            Thread.sleep(5 * 1000);
                        } catch (InterruptedException e) {
                            // ignore
                        }
                        count += 5;
                    } while (AppState.isSaving());
                    TabletopTool.getFrame().hideGlassPane();
                }
                try {
                    StaticMessageDialog progressDialog = new StaticMessageDialog(
                            I18N.getText("msg.info.campaignLoading"));
                    try {
                        // I'm going to get struck by lighting for writing code like this.
                        // CLEAN ME CLEAN ME CLEAN ME !   I NEED A SWINGWORKER!
                        TabletopTool.getFrame().showFilledGlassPane(progressDialog);

                        // Before we do anything, let's back it up
                        if (TabletopTool.getBackupManager() != null)
                            TabletopTool.getBackupManager().backup(campaignFile);

                        // Load
                        final PersistedCampaign campaign = PersistenceUtil.loadCampaign(campaignFile);
                        if (campaign != null) {
                            //                     current = TabletopTool.getFrame().getCurrentZoneRenderer();
                            //                     TabletopTool.getFrame().setCurrentZoneRenderer(null);
                            ImageManager.flush(); // Clear out the old campaign's images

                            AppState.setCampaignFile(campaignFile);
                            AppPreferences.setLoadDir(campaignFile.getParentFile());
                            AppMenuBar.getMruManager().addMRUCampaign(campaignFile);

                            /*
                             * Bypass the serialization when we are hosting the
                             * server.
                             */
                            //                     if (TabletopTool.isHostingServer() || TabletopTool.isPersonalServer()) {
                            //                        /*
                            //                         * TODO: This optimization doesn't work since
                            //                         * the player name isn't the right thing to use
                            //                         * to exclude this thread...
                            //                         */
                            //                        String playerName = TabletopTool.getPlayer().getName();
                            //                        String command = ServerCommand.COMMAND.setCampaign.name();
                            //                        TabletopTool.getServer().getMethodHandler().handleMethod(playerName, command, new Object[] { campaign.campaign });
                            //                     } else
                            {
                                TabletopTool.serverCommand().setCampaign(campaign.campaign);
                            }
                            TabletopTool.setCampaign(campaign.campaign, campaign.currentZone.getId());
                            ZoneRenderer current = TabletopTool.getFrame().getCurrentZoneRenderer();
                            if (campaign.currentView != null && current != null)
                                current.setZoneScale(campaign.currentView);
                            current.getZoneScale().reset();
                            TabletopTool.getAutoSaveManager().tidy();

                            // UI related stuff
                            TabletopTool.getFrame().getCommandPanel().setImpersonatedToken(null);
                            TabletopTool.getFrame().resetPanels();
                        }
                    } finally {
                        TabletopTool.getAutoSaveManager().restart();
                        TabletopTool.getFrame().hideGlassPane();
                    }
                } catch (IOException ioe) {
                    TabletopTool.showError("msg.error.failedLoadCampaign", ioe);
                }
            }
        }.start();
    }

    public static final Action SAVE_CAMPAIGN = new DefaultClientAction() {
        {
            init("action.saveCampaign");
        }

        @Override
        public boolean isAvailable() {
            return (TabletopTool.isHostingServer() || TabletopTool.getPlayer().isGM());
        }

        @Override
        public void execute(final ActionEvent ae) {
            Observer callback = null;
            if (ae.getSource() instanceof Observer)
                callback = (Observer) ae.getSource();
            if (AppState.getCampaignFile() == null) {
                doSaveCampaignAs(callback);
                return;
            }
            doSaveCampaign(TabletopTool.getCampaign(), AppState.getCampaignFile(), callback);
        }
    };

    public static final Action SAVE_CAMPAIGN_AS = new DefaultClientAction() {
        {
            init("action.saveCampaignAs");
        }

        @Override
        public boolean isAvailable() {
            return TabletopTool.isHostingServer() || TabletopTool.getPlayer().isGM();
        }

        @Override
        public void execute(final ActionEvent ae) {
            doSaveCampaignAs(null);
        }
    };

    private static void doSaveCampaign(final Campaign campaign, final File file, final Observer callback) {
        TabletopTool.getFrame()
                .showFilledGlassPane(new StaticMessageDialog(I18N.getText("msg.info.campaignSaving")));
        new SwingWorker<Object, Object>() {
            @Override
            protected Object doInBackground() throws Exception {
                if (AppState.isSaving()) {
                    return "Campaign currently being auto-saved.  Try again later."; // string error message
                }
                try {
                    AppState.setIsSaving(true);
                    TabletopTool.getAutoSaveManager().pause();

                    long start = System.currentTimeMillis();
                    PersistenceUtil.saveCampaign(campaign, file);
                    AppMenuBar.getMruManager().addMRUCampaign(AppState.getCampaignFile());
                    TabletopTool.getFrame().setStatusMessage(I18N.getString("msg.info.campaignSaved"));

                    // Minimum display time so people can see the message
                    try {
                        Thread.sleep(Math.max(0, 500 - (System.currentTimeMillis() - start)));
                    } catch (InterruptedException e) {
                        // Nothing to do
                    }
                    return null; // 'null' means everything worked; no errors
                } catch (IOException ioe) {
                    TabletopTool.showError("msg.error.failedSaveCampaign", ioe);
                } catch (Throwable t) {
                    TabletopTool.showError("msg.error.failedSaveCampaign", t);
                } finally {
                    AppState.setIsSaving(false);
                    TabletopTool.getAutoSaveManager().restart();
                }
                return "Failed due to exception"; // string error message
            }

            @Override
            protected void done() {
                TabletopTool.getFrame().hideGlassPane();
                Object obj = null;
                try {
                    obj = get();
                    if (obj instanceof String)
                        TabletopTool.showWarning((String) obj);
                } catch (Exception e) {
                    TabletopTool.showError("Exception during SwingWorker.get()?", e);
                }
                if (callback != null) {
                    callback.update(null, obj);
                }
            }
        }.execute();
    }

    public static void doSaveCampaignAs(final Observer callback) {
        Campaign campaign = TabletopTool.getCampaign();
        JFileChooser chooser = TabletopTool.getFrame().getSaveCmpgnFileChooser();

        int saveStatus = chooser.showSaveDialog(TabletopTool.getFrame());
        if (saveStatus == JFileChooser.APPROVE_OPTION) {
            File campaignFile = chooser.getSelectedFile();

            if (!campaignFile.getName().contains(".")) {
                campaignFile = new File(campaignFile.getAbsolutePath() + AppConstants.CAMPAIGN_FILE_EXTENSION);
            }
            if (campaignFile.exists() && !TabletopTool.confirm("msg.confirm.overwriteExistingCampaign")) {
                return;
            }
            doSaveCampaign(campaign, campaignFile, callback);

            AppState.setCampaignFile(campaignFile);
            AppPreferences.setSaveDir(campaignFile.getParentFile());
            AppMenuBar.getMruManager().addMRUCampaign(AppState.getCampaignFile());
            TabletopTool.getFrame().setTitleViaRenderer(TabletopTool.getFrame().getCurrentZoneRenderer());
        }
    }

    public static final DeveloperClientAction SAVE_MAP_AS = new DeveloperClientAction() {
        {
            init("action.saveMapAs");
        }

        @Override
        public boolean isAvailable() {
            return TabletopTool.isHostingServer()
                    || (TabletopTool.getPlayer() != null && TabletopTool.getPlayer().isGM());
        }

        @Override
        public void execute(ActionEvent ae) {
            ZoneRenderer zr = TabletopTool.getFrame().getCurrentZoneRenderer();
            JFileChooser chooser = TabletopTool.getFrame().getSaveFileChooser();
            chooser.setFileFilter(TabletopTool.getFrame().getMapFileFilter());
            chooser.setFileSelectionMode(JFileChooser.FILES_ONLY);
            chooser.setSelectedFile(new File(zr.getZone().getName()));
            if (chooser.showSaveDialog(TabletopTool.getFrame()) == JFileChooser.APPROVE_OPTION) {
                try {
                    File mapFile = chooser.getSelectedFile();
                    if (!mapFile.getName().contains(".")) {
                        mapFile = new File(mapFile.getAbsolutePath() + AppConstants.MAP_FILE_EXTENSION);
                    }
                    PersistenceUtil.saveMap(zr.getZone(), mapFile);
                    AppPreferences.setSaveDir(mapFile.getParentFile());
                    TabletopTool.showInformation("msg.info.mapSaved");
                } catch (IOException ioe) {
                    TabletopTool.showError("msg.error.failedSaveMap", ioe);
                }
            }
        }
    };

    public static abstract class LoadMapAction extends DeveloperClientAction {
        private boolean seenWarning = false;

        public boolean getSeenWarning() {
            return seenWarning;
        }

        public void setSeenWarning(boolean s) {
            seenWarning = s;
        }
    }

    /**
     * LOAD_MAP is the Action used to implement the loading of an externally
     * stored map into the current campaign. This Action is only available when
     * the current application is either hosting a server or is not connected to
     * a server.
     * 
     * Property used from <b>i18n.properties</b> is <code>action.loadMap</code>
     * 
     * @author FJE
     */
    public static final LoadMapAction LOAD_MAP = new LoadMapAction() {
        {
            init("action.loadMap");
        }

        @Override
        public boolean isAvailable() {
            //         return TabletopTool.isHostingServer() || TabletopTool.isPersonalServer();
            // I'd like to be able to use this instead as it's less restrictive, but it's safer to disallow for now.
            return TabletopTool.isHostingServer()
                    || (TabletopTool.getPlayer() != null && TabletopTool.getPlayer().isGM());
        }

        @Override
        public void execute(ActionEvent ae) {
            boolean isConnected = !TabletopTool.isHostingServer() && !TabletopTool.isPersonalServer();
            if (getSeenWarning() == false) {
                // If we're connected to a remote server and we are logged in as GM, this is true
                boolean isRemoteGM = isConnected && TabletopTool.getPlayer() != null
                        && TabletopTool.getPlayer().isGM();
                isRemoteGM = true;
                if (isRemoteGM) {
                    // Returns true if they select OK and false otherwise
                    //               setSeenWarning(TabletopTool.confirm("action.loadMap.warning"));
                    ImageIcon icon = null;
                    try {
                        Image img = ImageUtil.getImage("com/t3/client/image/book_open.png");
                        img = ImageUtil.createCompatibleImage(img, 16, 16, null);
                        icon = new ImageIcon(img);
                    } catch (IOException ex) {
                    }
                    JButton b = new JButton("Help", icon);
                    Object[] options = { b, "Yes", "No" };
                    int result = JOptionPane.showOptionDialog(TabletopTool.getFrame(),
                            // FIXME This string doesn't render as HTML properly -- no BOLD shows up?!
                            "<html>This is an <b>experimental</b> feature.  Save your campaign before using this feature (you are a GM logged in remotely).",
                            I18N.getText("msg.title.messageDialogConfirm"), JOptionPane.DEFAULT_OPTION,
                            JOptionPane.WARNING_MESSAGE, null, options, options[2]);
                    if (result == 1)
                        setSeenWarning(true); // Yes
                    else {
                        if (result == 0) { // Help
                            // TODO We really need a better way to disseminate this information.  Perhaps we could assign every
                            // external link a UUID, then have TabletopTool load a mapping from UUID-to-URL at runtime?  The
                            // mapping could come from the rptools.net site initially and be cached for future use, with a
                            // periodic "Check for new updates" option available from the Help menu...?
                            TabletopTool.showDocument("http://forums.rptools.net/viewtopic.php?f=3&t=23614");
                        }
                        return;
                    }
                } else
                    setSeenWarning(true);
            }
            if (getSeenWarning()) {
                JFileChooser chooser = new MapPreviewFileChooser();
                chooser.setDialogTitle(I18N.getText("msg.title.loadMap"));
                chooser.setFileSelectionMode(JFileChooser.FILES_ONLY);

                if (chooser.showOpenDialog(TabletopTool.getFrame()) == JFileChooser.APPROVE_OPTION) {
                    File mapFile = chooser.getSelectedFile();
                    loadMap(mapFile);
                }
            }
        }
    };

    private static class MapPreviewFileChooser extends PreviewPanelFileChooser {
        MapPreviewFileChooser() {
            super();
            addChoosableFileFilter(TabletopTool.getFrame().getMapFileFilter());
        }

        @Override
        protected File getImageFileOfSelectedFile() {
            if (getSelectedFile() == null) {
                return null;
            }
            return PersistenceUtil.getCampaignThumbnailFile(getSelectedFile().getName());
        }
    }

    public static void loadMap(final File mapFile) {
        new Thread() {
            @Override
            public void run() {
                try {
                    StaticMessageDialog progressDialog = new StaticMessageDialog(
                            I18N.getText("msg.info.mapLoading"));

                    // I'm going to get struck by lighting for writing code like this.
                    // CLEAN ME CLEAN ME CLEAN ME ! I NEED A SWINGWORKER !
                    TabletopTool.getFrame().showFilledGlassPane(progressDialog);

                    // Load
                    final PersistedMap map = PersistenceUtil.loadMap(mapFile);

                    if (map != null) {
                        AppPreferences.setLoadDir(mapFile.getParentFile());
                        if ((map.zone.getExposedArea() != null && !map.zone.getExposedArea().isEmpty())
                                || (map.zone.getExposedAreaMetaData() != null
                                        && !map.zone.getExposedAreaMetaData().isEmpty())) {
                            boolean ok = TabletopTool.confirm(
                                    "<html>Map contains exposed areas of fog.<br>Do you want to reset all of the fog?");
                            if (ok == true) {
                                // This fires a ModelChangeEvent, but that shouldn't matter
                                map.zone.clearExposedArea();
                            }
                        }
                        TabletopTool.addZone(map.zone);

                        TabletopTool.getAutoSaveManager().restart();
                        TabletopTool.getAutoSaveManager().tidy();

                        // Flush the images associated with the current
                        // campaign
                        // Do this juuuuuust before we get ready to show the
                        // new campaign, since we
                        // don't want the old campaign reloading images
                        // while we loaded the new campaign

                        // XXX (FJE) Is this call even needed for loading
                        // maps? Probably not...
                        ImageManager.flush();
                    }
                } finally {
                    TabletopTool.getFrame().hideGlassPane();
                }
            }
        }.start();
    }

    public static final Action CAMPAIGN_PROPERTIES = new DefaultClientAction() {
        {
            init("action.campaignProperties");
        }

        @Override
        public boolean isAvailable() {
            return TabletopTool.getPlayer().isGM();
        }

        @Override
        public void execute(ActionEvent ae) {
            Campaign campaign = TabletopTool.getCampaign();

            // TODO: There should probably be only one of these
            CampaignPropertiesDialog dialog = new CampaignPropertiesDialog(TabletopTool.getFrame());
            dialog.setCampaign(campaign);
            dialog.pack();
            dialog.setVisible(true);
            if (dialog.getStatus() == CampaignPropertiesDialog.Status.CANCEL) {
                return;
            }
            // TODO: Make this pass all properties, but we don't have that
            // framework yet, so send what we know the old fashioned way
            TabletopTool.serverCommand().updateCampaign(campaign.getCampaignProperties());
        }
    };

    public static class GridSizeAction extends DefaultClientAction {
        private final int size;

        public GridSizeAction(int size) {
            putValue(Action.NAME, Integer.toString(size));
            this.size = size;
        }

        @Override
        public boolean isSelected() {
            return AppState.getGridSize() == size;
        }

        @Override
        public void execute(ActionEvent arg0) {
            AppState.setGridSize(size);
            TabletopTool.getFrame().refresh();
        }
    }

    public static class DownloadRemoteLibraryAction extends DefaultClientAction {
        private final URL url;

        public DownloadRemoteLibraryAction(URL url) {
            this.url = url;
        }

        @Override
        public void execute(ActionEvent arg0) {
            if (!TabletopTool.confirm("confirm.downloadRemoteLibrary", url)) {
                return;
            }
            final RemoteFileDownloader downloader = new RemoteFileDownloader(url, TabletopTool.getFrame());
            new SwingWorker<Object, Object>() {
                @Override
                protected Object doInBackground() throws Exception {
                    try {
                        File dataFile = downloader.read();
                        if (dataFile == null) {
                            // Canceled
                            return null;
                        }
                        // Success
                        String libraryName = FileUtil.getNameWithoutExtension(url);
                        AppSetup.installLibrary(libraryName, dataFile.toURI().toURL());
                    } catch (IOException e) {
                        log.error("Could not download remote library: " + e, e);
                    }
                    return null;
                }

                @Override
                protected void done() {
                }
            }.execute();
        }
    }

    private static final int QUICK_MAP_ICON_SIZE = 25;

    public static class QuickMapAction extends AdminClientAction {
        private MD5Key assetId;

        public QuickMapAction(String name, File imagePath) {
            try {
                Asset asset = new Asset(name, FileUtils.readFileToByteArray(imagePath));
                assetId = asset.getId();

                // Make smaller
                BufferedImage iconImage = new BufferedImage(QUICK_MAP_ICON_SIZE, QUICK_MAP_ICON_SIZE,
                        Transparency.OPAQUE);
                Image image = TabletopTool.getThumbnailManager().getThumbnail(imagePath);

                Graphics2D g = iconImage.createGraphics();
                g.drawImage(image, 0, 0, QUICK_MAP_ICON_SIZE, QUICK_MAP_ICON_SIZE, null);
                g.dispose();

                putValue(Action.SMALL_ICON, new ImageIcon(iconImage));
                putValue(Action.NAME, name);

                // Put it in the cache for easy access
                AssetManager.putAsset(asset);

                // But don't use up any extra memory
                AssetManager.removeAsset(asset.getId());
            } catch (IOException ioe) {
                ioe.printStackTrace();
            }
            getActionList().add(this);
        }

        @Override
        public void execute(java.awt.event.ActionEvent e) {
            runBackground(new Runnable() {
                @Override
                public void run() {
                    Asset asset = AssetManager.getAsset(assetId);

                    Zone zone = ZoneFactory.createZone();
                    zone.setBackgroundPaint(new DrawableTexturePaint(asset.getId()));
                    zone.setName(asset.getName());

                    TabletopTool.addZone(zone);
                }
            });
        }
    };

    public static final Action NEW_MAP = new AdminClientAction() {
        {
            init("action.newMap");
        }

        @Override
        public void execute(java.awt.event.ActionEvent e) {
            runBackground(new Runnable() {
                @Override
                public void run() {
                    Zone zone = ZoneFactory.createZone();
                    MapPropertiesDialog newMapDialog = new MapPropertiesDialog(TabletopTool.getFrame());
                    newMapDialog.setZone(zone);

                    newMapDialog.setVisible(true);

                    if (newMapDialog.getStatus() == MapPropertiesDialog.Status.OK) {
                        TabletopTool.addZone(zone);
                    }
                }
            });
        }
    };

    public static final Action EDIT_MAP = new AdminClientAction() {
        {
            init("action.editMap");
        }

        @Override
        public void execute(java.awt.event.ActionEvent e) {
            runBackground(new Runnable() {
                @Override
                public void run() {
                    Zone zone = TabletopTool.getFrame().getCurrentZoneRenderer().getZone();
                    MapPropertiesDialog newMapDialog = new MapPropertiesDialog(TabletopTool.getFrame());
                    newMapDialog.setZone(zone);
                    newMapDialog.setVisible(true);
                    // Too many things can change to send them 1 by 1 to the client... just resend the zone
                    //               TabletopTool.serverCommand().setBoard(zone.getId(), zone.getMapAssetId(), zone.getBoardX(), zone.getBoardY());
                    TabletopTool.serverCommand().removeZone(zone.getId());
                    TabletopTool.serverCommand().putZone(zone);
                    TabletopTool.getFrame().getCurrentZoneRenderer().flush();
                }
            });
        }
    };

    public static final Action GATHER_DEBUG_INFO = new DefaultClientAction() {
        {
            init("action.gatherDebugInfo");
        }

        @Override
        public void execute(java.awt.event.ActionEvent e) {
            SysInfo.createAndShowGUI((String) getValue(Action.NAME));
        }
    };

    public static final Action ADD_RESOURCE_TO_LIBRARY = new DefaultClientAction() {
        {
            init("action.addIconSelector");
        }

        @Override
        public void execute(ActionEvent e) {
            runBackground(new Runnable() {
                @Override
                public void run() {
                    AddResourceDialog dialog = new AddResourceDialog();
                    dialog.showDialog();
                }
            });
        }
    };

    public static final Action EXIT = new DefaultClientAction() {
        {
            init("action.exit");
        }

        @Override
        public void execute(ActionEvent ae) {
            if (!TabletopTool.getFrame().confirmClose()) {
                return;
            } else {
                TabletopTool.getFrame().closingMaintenance();
            }
        }
    };

    /**
     * Toggle the drawing of measurements.
     */
    public static final Action TOGGLE_DRAW_MEASUREMENTS = new DefaultClientAction() {
        {
            init("action.toggleDrawMeasurements");
        }

        @Override
        public boolean isSelected() {
            return TabletopTool.getFrame().isPaintDrawingMeasurement();
        }

        @Override
        public void execute(ActionEvent ae) {
            TabletopTool.getFrame()
                    .setPaintDrawingMeasurement(!TabletopTool.getFrame().isPaintDrawingMeasurement());
        }
    };

    /**
     * Toggle drawing straight lines at double width on the line tool.
     */
    public static final Action TOGGLE_DOUBLE_WIDE = new DefaultClientAction() {
        {
            init("action.toggleDoubleWide");
        }

        @Override
        public boolean isSelected() {
            return AppState.useDoubleWideLine();
        }

        @Override
        public void execute(ActionEvent ae) {
            AppState.setUseDoubleWideLine(!AppState.useDoubleWideLine());
            if (TabletopTool.getFrame() != null && TabletopTool.getFrame().getCurrentZoneRenderer() != null)
                TabletopTool.getFrame().getCurrentZoneRenderer().repaint();
        }
    };

    public static class ToggleWindowAction extends ClientAction {
        private final MTFrame mtFrame;

        public ToggleWindowAction(MTFrame mtFrame) {
            this.mtFrame = mtFrame;
            init(mtFrame.getPropertyName());
        }

        @Override
        public boolean isSelected() {
            return TabletopTool.getFrame().getFrame(mtFrame).isVisible();
        }

        @Override
        public boolean isAvailable() {
            return true;
        }

        @Override
        public void execute(ActionEvent event) {
            DockableFrame frame = TabletopTool.getFrame().getFrame(mtFrame);
            if (frame.isVisible()) {
                TabletopTool.getFrame().getDockingManager().hideFrame(mtFrame.name());
            } else {
                TabletopTool.getFrame().getDockingManager().showFrame(mtFrame.name());
            }
        }
    }

    private static List<ClientAction> actionList;

    private static List<ClientAction> getActionList() {
        if (actionList == null) {
            actionList = new ArrayList<ClientAction>();
        }
        return actionList;
    }

    public static void updateActions() {
        for (ClientAction action : actionList) {
            action.setEnabled(action.isAvailable());
        }
        TabletopTool.getFrame().getToolbox().updateTools();
    }

    public static abstract class ClientAction extends AbstractAction {
        public void init(String key) {
            init(key, true);
        }

        public void init(String key, boolean addMenuShortcut) {
            I18N.setAction(key, this, addMenuShortcut);
            getActionList().add(this);
        }

        /**
         * This convenience function returns the KeyStroke that represents the
         * accelerator key used by the Action. This function can return
         * <code>null</code> because not all Actions have an associated
         * accelerator key defined, but it is currently only called by methods
         * that reference the {CUT,COPY,PASTE}_TOKEN Actions.
         * 
         * @return KeyStroke associated with the Action or <code>null</code>
         */
        public final KeyStroke getKeyStroke() {
            return (KeyStroke) getValue(Action.ACCELERATOR_KEY);
        }

        public abstract boolean isAvailable();

        public boolean isSelected() {
            return false;
        }

        @Override
        public final void actionPerformed(ActionEvent e) {
            execute(e);
            // System.out.println(getValue(Action.NAME));
            updateActions();
        }

        public abstract void execute(ActionEvent e);

        public void runBackground(final Runnable r) {
            new Thread() {
                @Override
                public void run() {
                    r.run();

                    updateActions();
                }
            }.start();
        }
    }

    /**
     * This class simply provides an implementation for
     * <code>isAvailable()</code> that returns <code>true</code> if the current
     * player is a GM.
     */
    public static abstract class AdminClientAction extends ClientAction {
        @Override
        public boolean isAvailable() {
            return TabletopTool.getPlayer().isGM();
        }
    }

    /**
     * This class simply provides an implementation for
     * <code>isAvailable()</code> that returns <code>true</code> if the current
     * player is a GM and there is a ZoneRenderer current.
     */
    public static abstract class ZoneAdminClientAction extends AdminClientAction {
        @Override
        public boolean isAvailable() {
            return super.isAvailable() && TabletopTool.getFrame().getCurrentZoneRenderer() != null;
        }
    }

    /**
     * This class simply provides an implementation for
     * <code>isAvailable()</code> that returns <code>true</code>.
     */
    public static abstract class DefaultClientAction extends ClientAction {
        @Override
        public boolean isAvailable() {
            return true;
        }
    }

    /**
     * This class provides an action that displays a url from I18N
     */
    public static class OpenUrlAction extends DefaultClientAction {
        public OpenUrlAction(String key) {
            // The init() method will load the "key", "key.accel", and "key.description".
            // The value of "key" will be used as the menu text, the accelerator is not used,
            // and the description will be the destination URL.  We also configure "key.icon"
            // to be the value of SMALL_ICON.  Only the Help menu uses these objects and
            // only the Help menu expects that field to be set...
            init(key);
            try {
                Image img = ImageUtil.getImage(I18N.getString(key + ".icon"));
                img = ImageUtil.createCompatibleImage(img, 16, 16, null);
                putValue(Action.SMALL_ICON, new ImageIcon(img));
            } catch (Exception e) {
                // Apparently the image is not available.
            }
        }

        @Override
        public void execute(ActionEvent e) {
            if (getValue(Action.SHORT_DESCRIPTION) != null)
                TabletopTool.showDocument((String) getValue(Action.SHORT_DESCRIPTION));
        }
    }

    /**
     * This class simply provides an implementation for
     * <code>isAvailable()</code> that returns <code>true</code> if the system
     * property T3_DEV is not set to "false". This allows it to contain the
     * version number of the compatible build for debugging purposes. For
     * example, if I'm working on a patch to 1.3.b54, I can set T3_DEV to
     * 1.3.b54 in order to test it against a 1.3.b54 client.
     */
    @SuppressWarnings("serial")
    public static abstract class DeveloperClientAction extends ClientAction {
        @Override
        public boolean isAvailable() {
            return System.getProperty("T3_DEV") != null && !"false".equals(System.getProperty("T3_DEV"));
        }
    }

    public static class OpenMRUCampaign extends AbstractAction {
        private final File campaignFile;

        public OpenMRUCampaign(File file, int position) {
            campaignFile = file;
            String label = position + " " + campaignFile.getName();
            putValue(Action.NAME, label);

            if (position <= 9) {
                int keyCode = KeyStroke.getKeyStroke(Integer.toString(position)).getKeyCode();
                putValue(Action.MNEMONIC_KEY, keyCode);
            }
            // Use the saved campaign thumbnail as a tooltip
            File thumbFile = PersistenceUtil.getCampaignThumbnailFile(campaignFile.getName());
            String htmlTip;

            if (thumbFile.exists()) {
                URL url = null;
                try {
                    url = thumbFile.toURI().toURL();
                } catch (MalformedURLException e) {
                    // Can this even happen?
                    TabletopTool.showWarning("Converting File to URL threw an exception?!", e);
                    return;
                }
                htmlTip = "<html><img src=\"" + url.toExternalForm() + "\"></html>";
                // The above should really be something like:
                // htmlTip = new Node("html").addChild("img").attr("src", thumbFile.toURI().toURL()).end();
                // The idea being that each method returns a proper value that allows them to be chained.
            } else {
                htmlTip = I18N.getText("msg.info.noCampaignPreview");
            }

            /*
             * There is some extra space appearing to the right of the images,
             * which sounds similar to what was reported in this bug (bottom
             * half): http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=5047379
             * Removing the mnemonic will remove this extra space.
             */
            putValue(Action.SHORT_DESCRIPTION, htmlTip);
        }

        @Override
        public void actionPerformed(ActionEvent ae) {
            if (TabletopTool.isCampaignDirty() && !TabletopTool.confirm("msg.confirm.loseChanges"))
                return;
            AppActions.loadCampaign(campaignFile);
        }
    }
}