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); + } +}