/* * 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.ui; import java.awt.Dimension; import java.awt.EventQueue; import java.awt.Graphics2D; import java.awt.GridLayout; import java.awt.Image; import java.awt.Rectangle; import java.awt.Transparency; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.image.BufferedImage; import java.io.BufferedInputStream; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.File; import java.lang.reflect.InvocationTargetException; import javax.imageio.ImageIO; import javax.swing.JButton; import javax.swing.JDialog; import javax.swing.JFileChooser; import com.jeta.forms.components.panel.FormPanel; import com.t3.client.TabletopTool; import com.t3.client.ui.zone.PlayerView; import com.t3.client.ui.zone.ZoneRenderer; import com.t3.language.I18N; import com.t3.model.Player; import com.t3.model.Zone; import com.t3.model.drawing.DrawablePaint; import com.t3.model.drawing.DrawableTexturePaint; import com.t3.net.FTPLocation; import com.t3.net.LocalLocation; import com.t3.net.Location; import com.t3.swing.SwingUtil; import com.t3.util.ImageManager; /** * Creates a dialog for performing a screen capture to a PNG file. * <p> * This uses a modal dialog based on an Abeille form. * It creates a PNG file at the resolution of the 'board' image/tile. * The file can be saved to disk or sent to an FTP location. * * @return a dialog box */ @SuppressWarnings("serial") public class ExportDialogOld extends JDialog { private static FormPanel formPanel; private static Location exportLocation; // // TODO: Abeille should auto-generate most of this code: // 1. We shouldn't have to synchronize the names of variables manually // 2. Specifying the name of a button in Abeille is the same as declaring a variable // 3. This code is always the same for every form, aside from the var names // 4. JAVA doesn't have a way to do abstract enumerated types, so we can't re-use the code except by copy/paste // 5. Abeille seems to be abandonded at this point (July 2010). The owner replied as recently as July 2009, but // seems not to have followed up. // /** * This enum is for ALL the radio buttons in the dialog, regardless of their grouping. * <p> * The names of the enums should be the same as the button names. */ public static enum ExportRadioButtons { // Format of enum declaration: // [Abeille Forms Designer button name] (default checked, default enabled) // Button Group 1 (not that it matters for this controller) TYPE_CURRENT_VIEW, TYPE_ENTIRE_MAP, // Button Group 2 VIEW_GM, VIEW_PLAYER, // Button Group 3 LAYERS_CURRENT, LAYERS_AS_SELECTED; private static FormPanel form; // // SetForm stores the form this is attached to // public static void setForm(FormPanel form) { ExportRadioButtons.form = form; for (ExportRadioButtons button : ExportRadioButtons.values()) { try { if (form.getRadioButton(button.toString()) == null) { throw new Exception("Export Dialog has a mis-matched enum: " + button.toString()); } button.addActionListener( new ActionListener() { @Override public void actionPerformed(ActionEvent evt ) { enforceButtonRules(); } } ); } catch (Exception ex) { TabletopTool.showError(I18N.getString("dialog.screenshot.radio.button.uiImplementationError"), ex); } } } // // Generic utility methods // NON-Static // public void setChecked(boolean checked) { form.getRadioButton(this.toString()).setSelected(checked); } public boolean isChecked() { return form.getRadioButton(this.toString()).isSelected(); } public void setEnabled(boolean enabled) { form.getRadioButton(this.toString()).setEnabled(enabled); } /** * Shortcut to allow clean code and type-checking of invocations of specific buttons */ public void addActionListener (ActionListener listener) { form.getRadioButton(this.toString()).addActionListener(listener); } /** * @return which of the buttons in the Type group is selected */ public static ExportRadioButtons getType() { if (ExportRadioButtons.TYPE_CURRENT_VIEW.isChecked()) { return TYPE_CURRENT_VIEW; } else if (ExportRadioButtons.TYPE_ENTIRE_MAP.isChecked()) { return TYPE_ENTIRE_MAP; } return null; } /** * @return which of the buttons in the View group is selected */ public static ExportRadioButtons getView() { if (ExportRadioButtons.VIEW_GM.isChecked()) { return VIEW_GM; } else if (ExportRadioButtons.VIEW_PLAYER.isChecked()) { return VIEW_PLAYER; } return null; } /** * @return which of the buttons in the Layers group is selected */ public static ExportRadioButtons getLayers() { if (ExportRadioButtons.LAYERS_CURRENT.isChecked()) { return LAYERS_CURRENT; } else if (ExportRadioButtons.LAYERS_AS_SELECTED.isChecked()) { return LAYERS_AS_SELECTED; } return null; } } /** * This enum is for all the checkboxes which select layers. * <p> * The names of the enums should be the same as the button names. */ private static enum ExportLayers { // enum_val (fieldName as per Abeille Forms Designer, playerCanModify) LAYER_TOKEN (true), LAYER_HIDDEN (false), LAYER_OBJECT (false), LAYER_BACKGROUND (false), LAYER_BOARD (false), LAYER_FOG (false), LAYER_VISIBILITY (true); private static FormPanel form; private final boolean playerCanModify; /** * Constructor, sets rules for export of this layer. * 'Player' is in reference to the Role type (Player vs. GM). */ ExportLayers (boolean playerCanModify) { this.playerCanModify = playerCanModify; } /** * Stores the form this is attached to, so we don't have to store * duplicate data locally (like selected and enabled). * Also perform some error checking, since we _are_ duplicating the * description of the form itself (like what buttons it has). * * @param form The FormPanel this dialog is part of. */ public static void setForm(FormPanel form) { ExportLayers.form = form; for (ExportLayers button : ExportLayers.values()) { try { if (form.getButton(button.toString()) == null) { throw new Exception("Export Dialog has a mis-matched enum: " + button.toString()); } } catch (Exception ex) { TabletopTool.showError(I18N.getString("dialog.screenshot.layer.button.uiImplementationError"), ex); } } } // // Misc utility methods // public void setChecked(boolean checked) { form.getButton(this.toString()).setSelected(checked); } public boolean isChecked() { return form.getButton(this.toString()).isSelected(); } public void setEnabled(boolean enabled) { form.getButton(this.toString()).setEnabled(enabled); } /** * Sets the layer-selection checkboxes to replicate the "current view". */ public void setToDefault() { final Zone zone = TabletopTool.getFrame().getCurrentZoneRenderer().getZone(); if (this == ExportLayers.LAYER_FOG) { ExportLayers.LAYER_FOG.setChecked(zone.hasFog()); } else if (this == ExportLayers.LAYER_VISIBILITY) { ExportLayers.LAYER_VISIBILITY.setChecked(zone.getVisionType() != Zone.VisionType.OFF); } else { setChecked(true); } } public static void setDefaultChecked() { // everything defaults to 'on' since the layers don't really have on/off capability // outside of this screenshot code for (ExportLayers layer : ExportLayers.values()) { layer.setChecked(true); } // however, some psuedo-layers do have a state, so set that appropriately final Zone zone = TabletopTool.getFrame().getCurrentZoneRenderer().getZone(); ExportLayers.LAYER_VISIBILITY.setChecked(zone.getVisionType() != Zone.VisionType.OFF); ExportLayers.LAYER_FOG.setChecked(zone.hasFog()); } public static void setDisabled() { for (ExportLayers layer : ExportLayers.values()) { layer.setEnabled(false); } } } /** * Ensures that the user can only check/uncheck boxes as appropriate. * For example, if "fog" is not enabled on the map, it cannot be * enabled for export. * <p> * This should get called during initialization and whenever * the radio buttons change. * <p> * The GM and Players have different rules, * to prevent players from gaining knowledge they should not have * using the screenshot (such as revealing things under other things * by disabling layers). Players can basically only turn off tokens, * to get an 'empty' version of the map. */ public static void enforceButtonRules() { if (!TabletopTool.getPlayer().isGM()) { ExportRadioButtons.VIEW_PLAYER.setChecked(true); ExportRadioButtons.VIEW_PLAYER.setEnabled(true); ExportRadioButtons.VIEW_GM.setEnabled(false); } if (ExportRadioButtons.LAYERS_CURRENT.isChecked()) { // By "current layers" we mean what you see in the editor, which is everything. // So disable mucking about with layers. formPanel.getLabel("LAYERS_LABEL").setEnabled(false); ExportLayers.setDefaultChecked(); ExportLayers.setDisabled(); } else /* if (ExportRadioButtons.LAYERS_AS_SELECTED.isChecked()) */ { formPanel.getLabel("LAYERS_LABEL").setEnabled(true); boolean isGM = ExportRadioButtons.VIEW_GM.isChecked(); final Zone zone = TabletopTool.getFrame().getCurrentZoneRenderer().getZone(); for (ExportLayers layer : ExportLayers.values()) { boolean enabled = isGM || layer.playerCanModify; // Regardless of whether it is a player or GM, // only enable fog and visibility check-boxes // when the map has those things turned on. switch (layer) { case LAYER_VISIBILITY: enabled &= (zone.getVisionType() != Zone.VisionType.OFF); break; case LAYER_FOG: enabled &= zone.hasFog(); break; } layer.setEnabled(enabled); if (!enabled) { layer.setToDefault(); } } } } public ExportDialogOld() { super(TabletopTool.getFrame(), "Export Screenshot", true); // The window uses about 1MB. Disposing frees this, but repeated uses // will cause more memory fragmentation. // MCL: I figure it's better to save the 1MB for low-mem systems, // but it would be even better to HIDE it, and then dispose() it // when the user clicks on the memory meter to free memory // setDefaultCloseOperation(HIDE_ON_CLOSE); setDefaultCloseOperation(DISPOSE_ON_CLOSE); // // Initialize the panel and button actions // formPanel = new FormPanel("com/t3/client/ui/forms/exportDialog.xml"); setLayout(new GridLayout()); add(formPanel); getRootPane().setDefaultButton((JButton) formPanel.getButton("exportButton")); pack(); ExportRadioButtons.setForm(formPanel); ExportLayers.setForm(formPanel); formPanel.getButton("exportButton").addActionListener( new ActionListener() { @Override public void actionPerformed(ActionEvent evt) { exportButtonAction(); } } ); formPanel.getButton("cancelButton").addActionListener( new ActionListener() { @Override public void actionPerformed(ActionEvent evt) { dispose(); } } ); formPanel.getButton("browseButton").addActionListener( new ActionListener() { @Override public void actionPerformed(ActionEvent evt) { browseButtonAction(); } } ); // Run this once to make sure the dialog is in a good starting state. ExportLayers.setDefaultChecked(); enforceButtonRules(); } @Override public void setVisible(boolean b) { // In case something changed while the dialog was closed... enforceButtonRules(); if (b) { SwingUtil.centerOver(this, TabletopTool.getFrame()); } super.setVisible(b); } private void exportButtonAction() { // This block is to allow preservation of existing dialog behavior: // Neither button is set when the dialog first appears, so we have to // make sure the user picks one. Presumably this is to force the user // to pay attention to this choice and not just accept a default. if (!(ExportRadioButtons.VIEW_GM.isChecked() || ExportRadioButtons.VIEW_PLAYER.isChecked())) { TabletopTool.showError(I18N.getString("dialog.screenshot.error.mustSelectView"), null); return; } // LOCATION // TODO: Show a progress dialog // TODO: Make this less fragile switch (formPanel.getTabbedPane("tabs").getSelectedIndex()) { case 0: File file = new File(formPanel.getText("locationTextField")); // PNG only supported for now if (!file.getName().toLowerCase().endsWith(".png")) { file = new File(file.getAbsolutePath() + ".png"); } exportLocation = new LocalLocation(file); break; case 1: String username = formPanel.getText("username"); String password = formPanel.getText("password"); String host = formPanel.getText("host"); String path = formPanel.getText("path"); // PNG only supported for now if (!path.toLowerCase().endsWith(".png")) { path += ".png"; } exportLocation = new FTPLocation(username, password, host, path); break; } try { screenCapture(); } catch (Exception ex) { TabletopTool.showError(I18N.getString("dialog.screenshot.error.failedExportingImage"), ex); } finally { dispose(); } } public void browseButtonAction() { JFileChooser chooser = new JFileChooser(); if (exportLocation instanceof LocalLocation) { chooser.setSelectedFile(((LocalLocation) exportLocation).getFile()); } if (chooser.showOpenDialog(ExportDialogOld.this) == JFileChooser.APPROVE_OPTION) { formPanel.setText("locationTextField", chooser.getSelectedFile().getAbsolutePath()); } } /** * This is the top-level screen-capture routine. It sends the resulting * PNG image to the location previously selected by the user. * TODO: It currently calls {@link TabletopTool.takeMapScreenShot()} for * "normal" screenshots, but that's just until this code is considered * stable enough. * * @throws Exception */ public void screenCapture() throws Exception { BufferedImage screenCap = null; TabletopTool.getFrame().setStatusMessage(I18N.getString("dialog.screenshot.msg.GeneratingScreenshot")); ExportRadioButtons type = ExportRadioButtons.getType(); try { switch (type) { case TYPE_CURRENT_VIEW: // This uses the original screenshot code: I didn't want to touch it, so I need // to pass it the same parameter it took before. Player.Role role = ExportRadioButtons.VIEW_GM.isChecked() ? Player.Role.GM : Player.Role.PLAYER; screenCap = TabletopTool.takeMapScreenShot(new PlayerView(role)); // since old screenshot code doesn't throw exceptions, look for null if (screenCap == null) { throw new Exception(I18N.getString("dialog.screenshot.error.failedImageGeneration")); } break; case TYPE_ENTIRE_MAP: screenCap = entireMapScreenShotWithLayers(); break; default: throw new Exception(I18N.getString("dialog.screenshot.error.invalidDialogSettings")); } TabletopTool.getFrame().setStatusMessage(I18N.getString("dialog.screenshot.msg.screenshotStreaming")); try(ByteArrayOutputStream imageOut = new ByteArrayOutputStream()) { ImageIO.write(screenCap, "png", imageOut); screenCap = null; // Free up the memory as soon as possible TabletopTool.getFrame().setStatusMessage(I18N.getString("dialog.screenshot.msg.screenshotSaving")); exportLocation.putContent(new BufferedInputStream(new ByteArrayInputStream(imageOut.toByteArray()))); } TabletopTool.getFrame().setStatusMessage(I18N.getString("dialog.screenshot.msg.screenshotSaved")); } catch (OutOfMemoryError e) { TabletopTool.showError("Out Of Memory", e); } catch (Exception ex) { TabletopTool.showError("screenCapture()", ex); } } public static Location getExportLocation() { return exportLocation; } public static void setExportLocation(Location loc) { exportLocation = loc; } /** * This is a wrapper that preserves the layer settings on the Zone object. * It calls {@link takeEntireMapScreenShot()} to to the real work. * * @return the image to be saved to a file */ private static BufferedImage entireMapScreenShotWithLayers() throws Exception, OutOfMemoryError { final Zone zone = TabletopTool.getFrame().getCurrentZoneRenderer().getZone(); // // Preserve settings // // psuedo-layers final Zone.VisionType savedVision = zone.getVisionType(); final boolean savedFog = zone.hasFog(); final boolean savedBoard = zone.drawBoard(); // real layers final boolean savedToken = Zone.Layer.TOKEN.isEnabled(); final boolean savedHidden = Zone.Layer.GM.isEnabled(); final boolean savedObject = Zone.Layer.OBJECT.isEnabled(); final boolean savedBackground = Zone.Layer.BACKGROUND.isEnabled(); // // set according to dialog options // zone.setHasFog(ExportLayers.LAYER_FOG.isChecked()); if (!ExportLayers.LAYER_VISIBILITY.isChecked()) zone.setVisionType(Zone.VisionType.OFF); zone.setDrawBoard(ExportLayers.LAYER_BOARD.isChecked()); Zone.Layer.TOKEN.setEnabled(ExportLayers.LAYER_TOKEN.isChecked()); Zone.Layer.GM.setEnabled(ExportLayers.LAYER_HIDDEN.isChecked()); Zone.Layer.OBJECT.setEnabled(ExportLayers.LAYER_OBJECT.isChecked()); Zone.Layer.BACKGROUND.setEnabled(ExportLayers.LAYER_BACKGROUND.isChecked()); // This 'cache invalidation' is handled by setZone inside takeEntireMapScreenShot() // but it should be more robust. // TabletopTool.getFrame().getCurrentZoneRenderer().invalidateCurrentViewCache(); // // screenshot! // MCL: NOTE: while turning off Fog, there is a possibility the players // may see the map flash for a second with fog turned off-- need to look into // whether this is true. BufferedImage image = null; try { image = takeEntireMapScreenShot(); } finally { // // Restore settings // zone.setHasFog(savedFog); zone.setVisionType(savedVision); zone.setDrawBoard(savedBoard); Zone.Layer.TOKEN.setEnabled(savedToken); Zone.Layer.GM.setEnabled(savedHidden); Zone.Layer.OBJECT.setEnabled(savedObject); Zone.Layer.BACKGROUND.setEnabled(savedBackground); // TabletopTool.getFrame().getCurrentZoneRenderer().invalidateCurrentViewCache(); } return image; } /** * Finds the extents of the map, then takes a 'screenshot' of that area. * If the user is the GM, the extents include every object and everything * that has any area, such as 'fog' and 'visibility' objects. * <p> * If a background tiling texture is used, the image is aligned to it, so * that it can be used on re-import as a new base map image. * <p> * If the user is a player (or GM posing as a player), the extents only * go as far as the revealed fog-of-war. * * @return the image to be saved */ private static BufferedImage takeEntireMapScreenShot() throws Exception, OutOfMemoryError { final ZoneRenderer renderer = TabletopTool.getFrame().getCurrentZoneRenderer(); if (renderer == null) { throw(new Exception("renderer = NULL")); } boolean viewAsPlayer = ExportRadioButtons.VIEW_PLAYER.isChecked(); // // First, figure out the 'extents' of the canvas // This will be later modified by the fog (for players), // and by the tiling texture (for re-importing) // final PlayerView view = new PlayerView(viewAsPlayer ? Player.Role.PLAYER : Player.Role.GM); Rectangle extents = renderer.zoneExtents(view); try { // Clip to what the players know about (if applicable). // This keeps the player from exporting the map to learn which // direction has more 'stuff' in it. if (viewAsPlayer) { Rectangle fogE = renderer.fogExtents(); // TabletopTool.showError(fogE.x + " " + fogE.y + " " + fogE.width + " " + fogE.height); if ((fogE.width < 0) || (fogE.height< 0)) { TabletopTool.showError(I18N.getString("dialog.screenshot.error.negativeFogExtents")); // Image is not clipped to show only fog-revealed areas!")); } else { extents = extents.intersection(fogE); } } } catch (Exception ex) { throw(new Exception(I18N.getString("dialog.screenshot.error.noArea"), ex)); } if ((extents == null) || (extents.width == 0) || (extents.height == 0)) { throw(new Exception(I18N.getString("dialog.screenshot.error.noArea"))); } // If output includes the tiling 'board' texture, move the upper-left corner // to an integer multiple of the background tile (so it matches up on import). // We don't need to move the lower-right corner because it doesn't matter for // aligning on importing. boolean drawBoard = ExportLayers.LAYER_BOARD.isChecked(); if (drawBoard) { DrawablePaint paint = renderer.getZone().getBackgroundPaint(); DrawableTexturePaint dummy = new DrawableTexturePaint(); Integer tileX = 0, tileY = 0; if (paint.getClass() == dummy.getClass()) { Image bgTexture = ImageManager.getImage(((DrawableTexturePaint) paint).getAsset().getId()); tileX = bgTexture.getWidth(null); tileY = bgTexture.getHeight(null); Integer x = ((int) Math.floor((float) extents.x / tileX)) * tileX; Integer y = ((int) Math.floor((float) extents.y / tileY)) * tileY; extents.width = extents.width + (extents.x - x); extents.height = extents.height + (extents.y - y); extents.x = x; extents.y = y; } } // Save the original state of the renderer to restore later. // Create a place to put the image, and // set up the renderer to encompass the whole extents of the map. Rectangle origBounds = renderer.getBounds(); Scale origScale = renderer.getZoneScale(); Dimension origSize = renderer.getSize(); BufferedImage image = null; try { image = new BufferedImage(extents.width, extents.height, Transparency.OPAQUE); } catch (OutOfMemoryError me) { throw new OutOfMemoryError("image size = " + extents.width + " x " + extents.height); } catch (Exception e) { throw new Exception("image size = " + extents.width + " x " + extents.height, e); } final Graphics2D g = image.createGraphics(); g.setClip(0,0,extents.width,extents.height); Scale s = new Scale(); s.setOffset(-extents.x, -extents.y); // Finally, draw the image. // Copied this thread concept from the original screenshot code in TabletopTool. // Have to do this on the EDT so that there aren't any odd side effects // of rendering using a renderer that's on screen. try { renderer.setZoneScale(s); renderer.setBounds(extents); renderer.setSize(extents.getSize()); if (!EventQueue.isDispatchThread()) { EventQueue.invokeAndWait(new Runnable() { @Override public void run() { renderer.renderZone(g, view); } }); } else { renderer.renderZone(g, view); } return image; } catch (OutOfMemoryError me) { throw new OutOfMemoryError("image size = " + extents.width + " x " + extents.height); } catch (InterruptedException ie) { TabletopTool.showError("While creating snapshot", ie); } catch (InvocationTargetException ite) { TabletopTool.showError("While creating snapshot", ite); } catch (Exception e) { throw new Exception("image size = " + extents.width + " x " + extents.height, e); } finally { g.dispose(); // Restore original state renderer.setBounds(origBounds); renderer.setZoneScale(origScale); renderer.setSize(origSize); } // This is just to avoid the compiler error: it should be unreachable return null; } }