diff --git a/src/games/strategy/triplea/ui/MapPanel.java b/src/games/strategy/triplea/ui/MapPanel.java
index b0b84c2ea3a..d4ebe482463 100644
--- a/src/games/strategy/triplea/ui/MapPanel.java
+++ b/src/games/strategy/triplea/ui/MapPanel.java
@@ -1,5 +1,7 @@
package games.strategy.triplea.ui;
+import static com.google.common.base.Preconditions.checkNotNull;
+
import java.awt.AlphaComposite;
import java.awt.Color;
import java.awt.Cursor;
@@ -461,11 +463,17 @@ public void setTopLeft(final int x, final int y) {
super.setTopLeft(x, y);
}
- // this one is useful for screenshots
- @Override
- public void print(final Graphics g) {
- final Graphics2D g2d = (Graphics2D) g;
- super.print(g2d);
+ /**
+ * Draws an image of the complete map to the specified graphics context.
+ *
+ *
+ * This method is useful for capturing screenshots. This method can be called from a thread other than the EDT.
+ *
+ *
+ * @param g The graphics context on which to draw the map; must not be {@code null}.
+ */
+ public void drawMapImage(final Graphics g) {
+ final Graphics2D g2d = (Graphics2D) checkNotNull(g);
// make sure we use the same data for the entire print
final GameData gameData = m_data;
final Rectangle2D.Double bounds = new Rectangle2D.Double(0, 0, getImageWidth(), getImageHeight());
diff --git a/src/games/strategy/triplea/ui/TripleAFrame.java b/src/games/strategy/triplea/ui/TripleAFrame.java
index 697682f29db..ac4eba39f1c 100644
--- a/src/games/strategy/triplea/ui/TripleAFrame.java
+++ b/src/games/strategy/triplea/ui/TripleAFrame.java
@@ -140,10 +140,10 @@
import games.strategy.triplea.formatter.MyFormatter;
import games.strategy.triplea.image.TileImageFactory;
import games.strategy.triplea.settings.scrolling.ScrollSettings;
+import games.strategy.triplea.ui.export.ScreenshotExporter;
import games.strategy.triplea.ui.history.HistoryDetailsPanel;
import games.strategy.triplea.ui.history.HistoryLog;
import games.strategy.triplea.ui.history.HistoryPanel;
-import games.strategy.triplea.ui.menubar.ExportMenu;
import games.strategy.triplea.ui.menubar.HelpMenu;
import games.strategy.triplea.ui.menubar.TripleAMenuBar;
import games.strategy.triplea.ui.screen.UnitsDrawer;
@@ -1735,7 +1735,7 @@ public void actionPerformed(final ActionEvent ae) {
@Override
public void actionPerformed(final ActionEvent ae) {
- ExportMenu.saveScreenshot(historyPanel.getCurrentPopupNode(), TripleAFrame.this, data);
+ ScreenshotExporter.exportScreenshot(TripleAFrame.this, data, historyPanel.getCurrentPopupNode());
historyPanel.clearCurrentPopupNode();
}
});
diff --git a/src/games/strategy/triplea/ui/export/ScreenshotExporter.java b/src/games/strategy/triplea/ui/export/ScreenshotExporter.java
new file mode 100644
index 00000000000..ea7f3c7f817
--- /dev/null
+++ b/src/games/strategy/triplea/ui/export/ScreenshotExporter.java
@@ -0,0 +1,137 @@
+package games.strategy.triplea.ui.export;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import java.awt.Color;
+import java.awt.Font;
+import java.awt.Graphics2D;
+import java.awt.geom.AffineTransform;
+import java.awt.image.BufferedImage;
+import java.io.File;
+import java.io.IOException;
+import java.util.Optional;
+
+import javax.imageio.ImageIO;
+import javax.swing.JOptionPane;
+import javax.swing.SwingUtilities;
+
+import games.strategy.engine.data.GameData;
+import games.strategy.engine.history.HistoryNode;
+import games.strategy.engine.history.Round;
+import games.strategy.triplea.ui.IUIContext;
+import games.strategy.triplea.ui.MapPanel;
+import games.strategy.triplea.ui.TripleAFrame;
+import games.strategy.triplea.ui.mapdata.MapData;
+import games.strategy.ui.SwingComponents;
+import games.strategy.ui.Util;
+
+public final class ScreenshotExporter {
+ private final TripleAFrame frame;
+
+ private ScreenshotExporter(final TripleAFrame frame) {
+ this.frame = frame;
+ }
+
+ /**
+ * Prompts the user for the file to which the screenshot will be saved and saves the screenshot for the specified game
+ * at the specified history step to that file.
+ *
+ * @param frame The frame associated with the game screenshot to export; must not be {@code null}.
+ * @param gameData The game data; must not be {@code null}.
+ * @param node The history step at which the game screenshot is to be captured; must not be {@code null}.
+ */
+ public static void exportScreenshot(final TripleAFrame frame, final GameData gameData, final HistoryNode node) {
+ checkNotNull(frame);
+ checkNotNull(gameData);
+ checkNotNull(node);
+
+ final ScreenshotExporter exporter = new ScreenshotExporter(frame);
+ exporter.promptSaveFile().ifPresent(file -> exporter.runSave(gameData, node, file));
+ }
+
+ private Optional promptSaveFile() {
+ return SwingComponents.promptSaveFile(frame, "png", "Saved Map Snapshots");
+ }
+
+ private void runSave(final GameData gameData, final HistoryNode node, final File file) {
+ SwingComponents.runWithProgressBar(frame, "Saving map snapshot...", () -> {
+ save(gameData, node, file);
+ return null;
+ }).whenComplete((ignore, e) -> {
+ SwingUtilities.invokeLater(() -> {
+ if (e == null) {
+ JOptionPane.showMessageDialog(frame, "Map Snapshot Saved", "Map Snapshot Saved",
+ JOptionPane.INFORMATION_MESSAGE);
+ } else {
+ JOptionPane.showMessageDialog(frame, e.getMessage(), "Error Saving Map Snapshot", JOptionPane.ERROR_MESSAGE);
+ }
+ });
+ });
+ }
+
+ private void save(final GameData gameData, final HistoryNode node, final File file) throws IOException {
+ // get round/step/player from history tree
+ int round = 0;
+ final Object[] pathFromRoot = node.getPath();
+ for (final Object pathNode : pathFromRoot) {
+ final HistoryNode curNode = (HistoryNode) pathNode;
+ if (curNode instanceof Round) {
+ round = ((Round) curNode).getRoundNo();
+ }
+ }
+ final IUIContext iuiContext = frame.getUIContext();
+ final double scale = iuiContext.getScale();
+ // print map panel to image
+ final MapPanel mapPanel = frame.getMapPanel();
+ final BufferedImage mapImage =
+ Util.createImage((int) (scale * mapPanel.getImageWidth()), (int) (scale * mapPanel.getImageHeight()), false);
+ final Graphics2D mapGraphics = mapImage.createGraphics();
+ try {
+ // workaround to get the whole map
+ // (otherwise the map is cut if current window is not on top of map)
+ final int xOffset = mapPanel.getXOffset();
+ final int yOffset = mapPanel.getYOffset();
+ mapPanel.setTopLeft(0, 0);
+ mapPanel.drawMapImage(mapGraphics);
+ mapPanel.setTopLeft(xOffset, yOffset);
+ // overlay title
+ Color title_color = iuiContext.getMapData().getColorProperty(MapData.PROPERTY_SCREENSHOT_TITLE_COLOR);
+ if (title_color == null) {
+ title_color = Color.BLACK;
+ }
+ final String s_title_x = iuiContext.getMapData().getProperty(MapData.PROPERTY_SCREENSHOT_TITLE_X);
+ final String s_title_y = iuiContext.getMapData().getProperty(MapData.PROPERTY_SCREENSHOT_TITLE_Y);
+ final String s_title_size = iuiContext.getMapData().getProperty(MapData.PROPERTY_SCREENSHOT_TITLE_FONT_SIZE);
+ int title_x;
+ int title_y;
+ int title_size;
+ try {
+ title_x = (int) (Integer.parseInt(s_title_x) * scale);
+ title_y = (int) (Integer.parseInt(s_title_y) * scale);
+ title_size = Integer.parseInt(s_title_size);
+ } catch (final NumberFormatException nfe) {
+ // choose safe defaults
+ title_x = (int) (15 * scale);
+ title_y = (int) (15 * scale);
+ title_size = 15;
+ }
+ // everything else should be scaled down onto map image
+ final AffineTransform transform = new AffineTransform();
+ transform.scale(scale, scale);
+ mapGraphics.setTransform(transform);
+ mapGraphics.setFont(new Font("Ariel", Font.BOLD, title_size));
+ mapGraphics.setColor(title_color);
+ if (iuiContext.getMapData().getBooleanProperty(MapData.PROPERTY_SCREENSHOT_TITLE_ENABLED)) {
+ mapGraphics.drawString(gameData.getGameName() + " Round " + round, title_x, title_y);
+ }
+
+ // save Image as .png
+ ImageIO.write(mapImage, "png", file);
+ } finally {
+ // Clean up objects. There might be some overkill here,
+ // but there were memory leaks that are fixed by some/all of these.
+ mapImage.flush();
+ mapGraphics.dispose();
+ }
+ }
+}
diff --git a/src/games/strategy/triplea/ui/menubar/ExportMenu.java b/src/games/strategy/triplea/ui/menubar/ExportMenu.java
index 38be2cb72e5..6768f726b96 100644
--- a/src/games/strategy/triplea/ui/menubar/ExportMenu.java
+++ b/src/games/strategy/triplea/ui/menubar/ExportMenu.java
@@ -1,11 +1,6 @@
package games.strategy.triplea.ui.menubar;
-import java.awt.Color;
-import java.awt.Font;
-import java.awt.Graphics2D;
import java.awt.event.KeyEvent;
-import java.awt.geom.AffineTransform;
-import java.awt.image.BufferedImage;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
@@ -20,7 +15,6 @@
import java.util.List;
import java.util.Set;
-import javax.imageio.ImageIO;
import javax.swing.AbstractAction;
import javax.swing.Action;
import javax.swing.JComponent;
@@ -30,7 +24,6 @@
import javax.swing.JMenuItem;
import javax.swing.JOptionPane;
import javax.swing.WindowConstants;
-import javax.swing.filechooser.FileFilter;
import javax.swing.tree.DefaultMutableTreeNode;
import games.strategy.debug.ClientLogger;
@@ -41,7 +34,6 @@
import games.strategy.engine.data.UnitType;
import games.strategy.engine.data.export.GameDataExporter;
import games.strategy.engine.framework.GameDataUtils;
-import games.strategy.engine.framework.ui.SaveGameFileChooser;
import games.strategy.engine.history.HistoryNode;
import games.strategy.engine.history.Round;
import games.strategy.engine.history.Step;
@@ -51,13 +43,11 @@
import games.strategy.triplea.printgenerator.SetupFrame;
import games.strategy.triplea.ui.ExtendedStats;
import games.strategy.triplea.ui.IUIContext;
-import games.strategy.triplea.ui.MapPanel;
import games.strategy.triplea.ui.TripleAFrame;
+import games.strategy.triplea.ui.export.ScreenshotExporter;
import games.strategy.triplea.ui.history.HistoryPanel;
-import games.strategy.triplea.ui.mapdata.MapData;
import games.strategy.triplea.util.PlayerOrderComparator;
import games.strategy.ui.SwingAction;
-import games.strategy.ui.Util;
import games.strategy.util.IllegalCharacterRemover;
import games.strategy.util.LocalizeHTML;
@@ -128,7 +118,7 @@ private void exportXMLFile() {
private void addSaveScreenshot(final JMenu parentMenu) {
final AbstractAction abstractAction = SwingAction.of("Export Map Snapshot", e -> {
-
+ // get current history node. if we are in history view, get the selected node.
final HistoryPanel historyPanel = frame.getHistoryPanel();
final HistoryNode curNode;
if (historyPanel == null) {
@@ -136,132 +126,11 @@ private void addSaveScreenshot(final JMenu parentMenu) {
} else {
curNode = historyPanel.getCurrentNode();
}
- saveScreenshot(curNode, frame, gameData);
+ ScreenshotExporter.exportScreenshot(frame, gameData, curNode);
});
parentMenu.add(abstractAction).setMnemonic(KeyEvent.VK_E);
}
- public static void saveScreenshot(final HistoryNode node, final TripleAFrame frame, final GameData gameData) {
- final FileFilter pngFilter = new FileFilter() {
- @Override
- public boolean accept(final File f) {
- if (f.isDirectory()) {
- return true;
- } else {
- return f.getName().endsWith(".png");
- }
- }
-
- @Override
- public String getDescription() {
- return "Saved Screenshots, *.png";
- }
- };
- final JFileChooser fileChooser = new SaveGameFileChooser();
- fileChooser.setFileFilter(pngFilter);
- final int rVal = fileChooser.showSaveDialog(null);
- if (rVal == JFileChooser.APPROVE_OPTION) {
- File f = fileChooser.getSelectedFile();
- if (!f.getName().toLowerCase().endsWith(".png")) {
- f = new File(f.getParent(), f.getName() + ".png");
- }
- // A small warning so users will not over-write a file,
- if (f.exists()) {
- final int choice =
- JOptionPane.showConfirmDialog(null, "A file by that name already exists. Do you wish to over write it?",
- "Over-write?", JOptionPane.YES_NO_OPTION, JOptionPane.WARNING_MESSAGE);
- if (choice != JOptionPane.OK_OPTION) {
- return;
- }
- }
- final File file = f;
- final Runnable t = () -> {
- if (saveScreenshot(node, file, frame, gameData)) {
- JOptionPane.showMessageDialog(null, "Map Snapshot Saved", "Map Snapshot Saved",
- JOptionPane.INFORMATION_MESSAGE);
- }
- };
- SwingAction.invokeAndWait(t);
- }
- }
-
- private static boolean saveScreenshot(final HistoryNode node, final File file, final TripleAFrame frame,
- final GameData gameData) {
- // get current history node. if we are in history view, get the selected node.
- boolean retval = true;
- // get round/step/player from history tree
- int round = 0;
- final Object[] pathFromRoot = node.getPath();
- for (final Object pathNode : pathFromRoot) {
- final HistoryNode curNode = (HistoryNode) pathNode;
- if (curNode instanceof Round) {
- round = ((Round) curNode).getRoundNo();
- }
- }
- final IUIContext iuiContext = frame.getUIContext();
- final double scale = iuiContext.getScale();
- // print map panel to image
-
- final MapPanel mapPanel = frame.getMapPanel();
- final BufferedImage mapImage =
- Util.createImage((int) (scale * mapPanel.getImageWidth()), (int) (scale * mapPanel.getImageHeight()), false);
- final Graphics2D mapGraphics = mapImage.createGraphics();
- try {
- // workaround to get the whole map
- // (otherwise the map is cut if current window is not on top of map)
- final int xOffset = mapPanel.getXOffset();
- final int yOffset = mapPanel.getYOffset();
- mapPanel.setTopLeft(0, 0);
- mapPanel.print(mapGraphics);
- mapPanel.setTopLeft(xOffset, yOffset);
- // overlay title
- Color title_color = iuiContext.getMapData().getColorProperty(MapData.PROPERTY_SCREENSHOT_TITLE_COLOR);
- if (title_color == null) {
- title_color = Color.BLACK;
- }
- final String s_title_x = iuiContext.getMapData().getProperty(MapData.PROPERTY_SCREENSHOT_TITLE_X);
- final String s_title_y = iuiContext.getMapData().getProperty(MapData.PROPERTY_SCREENSHOT_TITLE_Y);
- final String s_title_size = iuiContext.getMapData().getProperty(MapData.PROPERTY_SCREENSHOT_TITLE_FONT_SIZE);
- int title_x;
- int title_y;
- int title_size;
- try {
- title_x = (int) (Integer.parseInt(s_title_x) * scale);
- title_y = (int) (Integer.parseInt(s_title_y) * scale);
- title_size = Integer.parseInt(s_title_size);
- } catch (final NumberFormatException nfe) {
- // choose safe defaults
- title_x = (int) (15 * scale);
- title_y = (int) (15 * scale);
- title_size = 15;
- }
- // everything else should be scaled down onto map image
- final AffineTransform transform = new AffineTransform();
- transform.scale(scale, scale);
- mapGraphics.setTransform(transform);
- mapGraphics.setFont(new Font("Ariel", Font.BOLD, title_size));
- mapGraphics.setColor(title_color);
- if (iuiContext.getMapData().getBooleanProperty(MapData.PROPERTY_SCREENSHOT_TITLE_ENABLED)) {
- mapGraphics.drawString(gameData.getGameName() + " Round " + round, title_x, title_y);
- }
-
- // save Image as .png
- try {
- ImageIO.write(mapImage, "png", file);
- } catch (final Exception e2) {
- e2.printStackTrace();
- JOptionPane.showMessageDialog(frame, e2.getMessage(), "Error saving Screenshot", JOptionPane.OK_OPTION);
- retval = false;
- }
- // Clean up objects. There might be some overkill here,
- // but there were memory leaks that are fixed by some/all of these.
- } finally {
- mapImage.flush();
- mapGraphics.dispose();
- }
- return retval;
- }
-
private void addExportStatsFull(final JMenu parentMenu) {
final Action showDiceStats = SwingAction.of("Export Full Game Stats", e -> createAndSaveStats(true));
parentMenu.add(showDiceStats).setMnemonic(KeyEvent.VK_F);
diff --git a/src/games/strategy/ui/ProgressDialog.java b/src/games/strategy/ui/ProgressDialog.java
new file mode 100644
index 00000000000..6198a19ff9a
--- /dev/null
+++ b/src/games/strategy/ui/ProgressDialog.java
@@ -0,0 +1,63 @@
+package games.strategy.ui;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import java.awt.BorderLayout;
+import java.awt.Color;
+import java.awt.Component;
+import java.awt.Frame;
+
+import javax.swing.JDialog;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JProgressBar;
+import javax.swing.border.EmptyBorder;
+import javax.swing.border.LineBorder;
+
+/**
+ * A modal dialog used to display indeterminate progress during an operation.
+ */
+public final class ProgressDialog extends JDialog {
+ private static final long serialVersionUID = -590470596784214914L;
+
+ /**
+ * Initializes a new instance of the {@code ProgressDialog} class.
+ *
+ * @param owner The {@code Frame} from which the dialog is displayed or {@code null} to use a shared, hidden frame as
+ * the owner of the dialog.
+ * @param message The progress message; must not be {@code null}.
+ */
+ public ProgressDialog(final Frame owner, final String message) {
+ super(owner, true);
+
+ checkNotNull(message);
+
+ setDefaultCloseOperation(DO_NOTHING_ON_CLOSE);
+ setUndecorated(true);
+
+ setLayout(new BorderLayout());
+ add(createContent(message), BorderLayout.CENTER);
+
+ pack();
+
+ setSize(200, 80);
+ setLocationRelativeTo(owner);
+ }
+
+ private static Component createContent(final String message) {
+ final JPanel panel = new JPanel();
+ panel.setBorder(new LineBorder(Color.BLACK));
+ panel.setLayout(new BorderLayout());
+
+ final JLabel label = new JLabel(message);
+ label.setBorder(new EmptyBorder(10, 10, 10, 10));
+ panel.add(BorderLayout.NORTH, label);
+
+ final JProgressBar progressBar = new JProgressBar();
+ progressBar.setBorder(new EmptyBorder(10, 10, 10, 10));
+ progressBar.setIndeterminate(true);
+ panel.add(progressBar, BorderLayout.CENTER);
+
+ return panel;
+ }
+}
diff --git a/src/games/strategy/ui/SwingComponents.java b/src/games/strategy/ui/SwingComponents.java
index e333f0e15c6..0d8683afc6c 100644
--- a/src/games/strategy/ui/SwingComponents.java
+++ b/src/games/strategy/ui/SwingComponents.java
@@ -1,5 +1,7 @@
package games.strategy.ui;
+import static com.google.common.base.Preconditions.checkNotNull;
+
import java.awt.BorderLayout;
import java.awt.Component;
import java.awt.Frame;
@@ -9,10 +11,15 @@
import java.awt.event.KeyEvent;
import java.awt.event.WindowAdapter;
import java.awt.event.WindowEvent;
+import java.io.File;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
+import java.util.Optional;
import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutionException;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -25,6 +32,7 @@
import javax.swing.JComponent;
import javax.swing.JDialog;
import javax.swing.JEditorPane;
+import javax.swing.JFileChooser;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JList;
@@ -36,14 +44,21 @@
import javax.swing.JTabbedPane;
import javax.swing.KeyStroke;
import javax.swing.SwingUtilities;
+import javax.swing.SwingWorker;
import javax.swing.border.Border;
import javax.swing.border.EmptyBorder;
+import javax.swing.filechooser.FileFilter;
+import javax.swing.filechooser.FileNameExtensionFilter;
+
+import com.google.common.annotations.VisibleForTesting;
+import games.strategy.debug.ClientLogger;
import games.strategy.engine.framework.startup.ui.MainFrame;
import games.strategy.net.OpenFileUtility;
import games.strategy.triplea.UrlConstants;
public class SwingComponents {
+ private static final String PERIOD = ".";
public static JTabbedPane newJTabbedPane() {
return new JTabbedPaneWithFixedWidthTabs();
@@ -279,4 +294,124 @@ public static JMenu newJMenu(final String menuTitle, final KeyboardCode keyboard
menu.setMnemonic(keyboardCode.getSwingKeyEventCode());
return menu;
}
+
+ /**
+ * Displays a file chooser from which the user can select a file to save.
+ *
+ *
+ * The user will be asked to confirm the save if the selected file already exists.
+ *
+ *
+ * @param parent Determines the {@code Frame} in which the dialog is displayed; if {@code null}, or if {@code parent}
+ * has no {@code Frame}, a default {@code Frame} is used.
+ * @param fileExtension The extension of the file to save, with or without a leading period; must not be {@code null}.
+ * This extension will be automatically appended to the file name if not present.
+ * @param fileExtensionDescription The description of the file extension to be displayed in the file chooser; must not
+ * be {@code null}.
+ *
+ * @return The file selected by the user or empty if the user aborted the save; never {@code null}.
+ */
+ public static Optional promptSaveFile(final Component parent, final String fileExtension,
+ final String fileExtensionDescription) {
+ checkNotNull(fileExtension);
+ checkNotNull(fileExtensionDescription);
+
+ final JFileChooser fileChooser = new JFileChooser() {
+ private static final long serialVersionUID = -136588718021703367L;
+
+ @Override
+ public void approveSelection() {
+ final File file = appendExtensionIfAbsent(getSelectedFile(), fileExtension);
+ setSelectedFile(file);
+ if (file.exists()) {
+ final int result = JOptionPane.showConfirmDialog(
+ parent,
+ String.format("A file named \"%s\" already exists. Do you want to replace it?", file.getName()),
+ "Confirm Save",
+ JOptionPane.YES_NO_OPTION,
+ JOptionPane.WARNING_MESSAGE);
+ if (result != JOptionPane.YES_OPTION) {
+ return;
+ }
+ }
+
+ super.approveSelection();
+ }
+ };
+
+ final String fileExtensionWithoutLeadingPeriod = extensionWithoutLeadingPeriod(fileExtension);
+ final FileFilter fileFilter = new FileNameExtensionFilter(
+ String.format("%s, *.%s", fileExtensionDescription, fileExtensionWithoutLeadingPeriod),
+ fileExtensionWithoutLeadingPeriod);
+ fileChooser.setFileFilter(fileFilter);
+
+ final int result = fileChooser.showSaveDialog(parent);
+ return (result == JFileChooser.APPROVE_OPTION) ? Optional.of(fileChooser.getSelectedFile()) : Optional.empty();
+ }
+
+ @VisibleForTesting
+ static File appendExtensionIfAbsent(final File file, final String extension) {
+ final String extensionWithLeadingPeriod = extensionWithLeadingPeriod(extension);
+ if (file.getName().toLowerCase().endsWith(extensionWithLeadingPeriod.toLowerCase())) {
+ return file;
+ }
+
+ return new File(file.getParentFile(), file.getName() + extensionWithLeadingPeriod);
+ }
+
+ @VisibleForTesting
+ static String extensionWithLeadingPeriod(final String extension) {
+ return extension.isEmpty() || extension.startsWith(PERIOD) ? extension : PERIOD + extension;
+ }
+
+ @VisibleForTesting
+ static String extensionWithoutLeadingPeriod(final String extension) {
+ return extension.startsWith(PERIOD) ? extension.substring(PERIOD.length()) : extension;
+ }
+
+ /**
+ * Runs the specified task on a background thread while displaying a progress dialog.
+ *
+ * @param The type of the task result.
+ *
+ * @param frame The {@code Frame} from which the progress dialog is displayed or {@code null} to use a shared, hidden
+ * frame as the owner of the progress dialog.
+ * @param message The message to display in the progress dialog; must not be {@code null}.
+ * @param task The task to be executed; must not be {@code null}.
+ *
+ * @return A promise that resolves to the result of the task; never {@code null}.
+ */
+ public static CompletableFuture runWithProgressBar(
+ final Frame frame,
+ final String message,
+ final Callable task) {
+ checkNotNull(message);
+ checkNotNull(task);
+
+ final CompletableFuture promise = new CompletableFuture<>();
+ final SwingWorker worker = new SwingWorker() {
+ @Override
+ protected T doInBackground() throws Exception {
+ return task.call();
+ }
+
+ @Override
+ protected void done() {
+ try {
+ promise.complete(get());
+ } catch (final ExecutionException e) {
+ promise.completeExceptionally(e.getCause());
+ } catch (final InterruptedException e) {
+ promise.completeExceptionally(e);
+ // TODO: consider if re-interrupting the thread is the right thing to do when we can't throw
+ // InterruptedException (see https://www.ibm.com/developerworks/library/j-jtp05236/)
+ ClientLogger.logQuietly(e);
+ }
+ }
+ };
+ final ProgressDialog progressDialog = new ProgressDialog(frame, message);
+ worker.addPropertyChangeListener(new SwingWorkerCompletionWaiter(progressDialog));
+ worker.execute();
+ return promise;
+ }
}
diff --git a/src/games/strategy/ui/SwingWorkerCompletionWaiter.java b/src/games/strategy/ui/SwingWorkerCompletionWaiter.java
new file mode 100644
index 00000000000..714340ca3f2
--- /dev/null
+++ b/src/games/strategy/ui/SwingWorkerCompletionWaiter.java
@@ -0,0 +1,86 @@
+package games.strategy.ui;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import java.awt.Window;
+import java.beans.PropertyChangeEvent;
+import java.beans.PropertyChangeListener;
+
+import javax.swing.SwingWorker;
+
+import com.google.common.annotations.VisibleForTesting;
+
+/**
+ * Manages the display of a window during the execution of a {@code SwingWorker}.
+ *
+ *
+ * Use this class when it is necessary to display progress during a potentially long-running operation. If the Event
+ * Dispatch Thread must be blocked until the operation is complete (i.e. the user should not be allowed to perform
+ * any other actions in the UI), it is recommended you use a modal dialog.
+ *
+ *
+ *
+ * For example:
+ *
+ *
+ *
+ * JDialog dialog = new JDialog(owner, true);
+ * swingWorker.addPropertyChangeListener(
+ * new SwingWorkerCompletionWaiter(dialog));
+ * swingWorker.execute();
+ * // the dialog will be visible between the
+ * // SwingWorker STARTED and DONE states
+ *
+ */
+public final class SwingWorkerCompletionWaiter implements PropertyChangeListener {
+ static final String SWING_WORKER_STATE_PROPERTY_NAME = "state";
+
+ private final ProgressWindow progressWindow;
+
+ /**
+ * Initializes a new instance of the {@code SwingWorkerCompletionWaiter} class.
+ *
+ * @param window The window to display while the operation is in progress; must not be {@code null}.
+ */
+ public SwingWorkerCompletionWaiter(final Window window) {
+ this.progressWindow = ProgressWindow.fromWindow(checkNotNull(window));
+ }
+
+ @VisibleForTesting
+ SwingWorkerCompletionWaiter(final ProgressWindow progressWindow) {
+ this.progressWindow = progressWindow;
+ }
+
+ @Override
+ public void propertyChange(final PropertyChangeEvent event) {
+ if (SWING_WORKER_STATE_PROPERTY_NAME.equals(event.getPropertyName())) {
+ if (SwingWorker.StateValue.STARTED == event.getNewValue()) {
+ progressWindow.open();
+ } else if (SwingWorker.StateValue.DONE == event.getNewValue()) {
+ progressWindow.close();
+ }
+ }
+ }
+
+ @VisibleForTesting
+ static interface ProgressWindow {
+ void close();
+
+ void open();
+
+ static ProgressWindow fromWindow(final Window window) {
+ return new ProgressWindow() {
+ @Override
+ public void close() {
+ window.setVisible(false);
+ window.dispose();
+ }
+
+ @Override
+ public void open() {
+ window.setVisible(true);
+ }
+ };
+ }
+ }
+}
diff --git a/test/games/strategy/ui/SwingComponentsTest.java b/test/games/strategy/ui/SwingComponentsTest.java
new file mode 100644
index 00000000000..30d901ac548
--- /dev/null
+++ b/test/games/strategy/ui/SwingComponentsTest.java
@@ -0,0 +1,68 @@
+package games.strategy.ui;
+
+import static games.strategy.ui.SwingComponents.appendExtensionIfAbsent;
+import static games.strategy.ui.SwingComponents.extensionWithLeadingPeriod;
+import static games.strategy.ui.SwingComponents.extensionWithoutLeadingPeriod;
+import static org.hamcrest.Matchers.is;
+import static org.junit.Assert.assertThat;
+
+import java.io.File;
+
+import org.junit.Test;
+
+public final class SwingComponentsTest {
+ @Test
+ public void testAppendExtensionIfAbsent_ShouldAppendExtensionWhenExtensionAbsent() {
+ assertThat(appendExtensionIfAbsent(new File("path/file.aaa"), "bbb"), is(new File("path/file.aaa.bbb")));
+ assertThat(appendExtensionIfAbsent(new File("path/filebbb"), "bbb"), is(new File("path/filebbb.bbb")));
+ }
+
+ @Test
+ public void testAppendExtensionIfAbsent_ShouldNotAppendExtensionWhenExtensionPresent() {
+ assertThat(appendExtensionIfAbsent(new File("path/file.bbb"), "bbb"), is(new File("path/file.bbb")));
+ }
+
+ @Test
+ public void testAppendExtensionIfAbsent_ShouldHandleExtensionThatStartsWithPeriod() {
+ assertThat(appendExtensionIfAbsent(new File("path/file.aaa"), ".bbb"), is(new File("path/file.aaa.bbb")));
+ }
+
+ @Test
+ public void testAppendExtensionIfAbsent_ShouldUseCaseInsensitiveComparisonForExtension() {
+ assertThat(appendExtensionIfAbsent(new File("path/file.bBb"), "BbB"), is(new File("path/file.bBb")));
+ }
+
+ @Test
+ public void testExtensionWithLeadingPeriod() {
+ assertThat(extensionWithLeadingPeriod(""), is(""));
+
+ assertThat(extensionWithLeadingPeriod("a"), is(".a"));
+ assertThat(extensionWithLeadingPeriod(".a"), is(".a"));
+
+ assertThat(extensionWithLeadingPeriod("aa"), is(".aa"));
+ assertThat(extensionWithLeadingPeriod(".aa"), is(".aa"));
+
+ assertThat(extensionWithLeadingPeriod("aaa"), is(".aaa"));
+ assertThat(extensionWithLeadingPeriod(".aaa"), is(".aaa"));
+
+ assertThat(extensionWithLeadingPeriod("aaa.aaa"), is(".aaa.aaa"));
+ assertThat(extensionWithLeadingPeriod(".aaa.aaa"), is(".aaa.aaa"));
+ }
+
+ @Test
+ public void testExtensionWithoutLeadingPeriod() {
+ assertThat(extensionWithoutLeadingPeriod(""), is(""));
+
+ assertThat(extensionWithoutLeadingPeriod("a"), is("a"));
+ assertThat(extensionWithoutLeadingPeriod(".a"), is("a"));
+
+ assertThat(extensionWithoutLeadingPeriod("aa"), is("aa"));
+ assertThat(extensionWithoutLeadingPeriod(".aa"), is("aa"));
+
+ assertThat(extensionWithoutLeadingPeriod("aaa"), is("aaa"));
+ assertThat(extensionWithoutLeadingPeriod(".aaa"), is("aaa"));
+
+ assertThat(extensionWithoutLeadingPeriod("aaa.aaa"), is("aaa.aaa"));
+ assertThat(extensionWithoutLeadingPeriod(".aaa.aaa"), is("aaa.aaa"));
+ }
+}
diff --git a/test/games/strategy/ui/SwingWorkerCompletionWaiterTest.java b/test/games/strategy/ui/SwingWorkerCompletionWaiterTest.java
new file mode 100644
index 00000000000..abbf3e543ef
--- /dev/null
+++ b/test/games/strategy/ui/SwingWorkerCompletionWaiterTest.java
@@ -0,0 +1,44 @@
+package games.strategy.ui;
+
+import static org.mockito.Mockito.verify;
+
+import java.beans.PropertyChangeEvent;
+
+import javax.swing.SwingWorker;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.runners.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public final class SwingWorkerCompletionWaiterTest {
+ @InjectMocks
+ private SwingWorkerCompletionWaiter waiter;
+
+ @Mock
+ private SwingWorkerCompletionWaiter.ProgressWindow progressWindow;
+
+ @Test
+ public void testShouldOpenProgressWindowWhenWorkerStarted() {
+ waiter.propertyChange(newSwingWorkerStateEvent(SwingWorker.StateValue.STARTED));
+
+ verify(progressWindow).open();
+ }
+
+ @Test
+ public void testShouldCloseProgressWindowWhenWorkerDone() {
+ waiter.propertyChange(newSwingWorkerStateEvent(SwingWorker.StateValue.DONE));
+
+ verify(progressWindow).close();
+ }
+
+ private static PropertyChangeEvent newSwingWorkerStateEvent(final SwingWorker.StateValue stateValue) {
+ return new PropertyChangeEvent(
+ new Object(),
+ SwingWorkerCompletionWaiter.SWING_WORKER_STATE_PROPERTY_NAME,
+ null,
+ stateValue);
+ }
+}