net.rptools.tokentool.controller.TokenTool_Controller.java Source code

Java tutorial

Introduction

Here is the source code for net.rptools.tokentool.controller.TokenTool_Controller.java

Source

/*
 * This software Copyright by the RPTools.net development team, and licensed under the Affero GPL Version 3 or, at your option, any later version.
 *
 * TokenTool Source Code is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
 *
 * You should have received a copy of the GNU Affero General Public License * along with this source Code. If not, please visit <http://www.gnu.org/licenses/> and specifically the Affero license text
 * at <http://www.gnu.org/licenses/agpl.html>.
 */
package net.rptools.tokentool.controller;

import java.awt.Point;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.ListIterator;
import java.util.Map;
import java.util.Map.Entry;
import java.util.NavigableSet;
import java.util.TreeSet;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.UnaryOperator;
import javax.imageio.ImageIO;

import org.apache.commons.io.FilenameUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

import javafx.animation.FadeTransition;
import javafx.application.Platform;
import javafx.beans.binding.Bindings;
import javafx.beans.property.ObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.concurrent.Task;
import javafx.css.PseudoClass;
import javafx.embed.swing.SwingFXUtils;
import javafx.event.ActionEvent;
import javafx.fxml.FXML;
import javafx.geometry.Insets;
import javafx.scene.Cursor;
import javafx.scene.Group;
import javafx.scene.control.CheckBox;
import javafx.scene.control.ColorPicker;
import javafx.scene.control.Label;
import javafx.scene.control.MenuItem;
import javafx.scene.control.ProgressBar;
import javafx.scene.control.ScrollPane;
import javafx.scene.control.Slider;
import javafx.scene.control.Spinner;
import javafx.scene.control.TextField;
import javafx.scene.control.TextFormatter;
import javafx.scene.control.TextFormatter.Change;
import javafx.scene.control.TitledPane;
import javafx.scene.control.ToggleButton;
import javafx.scene.control.TreeCell;
import javafx.scene.control.TreeItem;
import javafx.scene.control.TreeView;
import javafx.scene.effect.GaussianBlur;
import javafx.scene.effect.Glow;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.Clipboard;
import javafx.scene.input.ClipboardContent;
import javafx.scene.input.DragEvent;
import javafx.scene.input.Dragboard;
import javafx.scene.input.MouseDragEvent;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.RotateEvent;
import javafx.scene.input.ScrollEvent;
import javafx.scene.input.TransferMode;
import javafx.scene.input.ZoomEvent;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.CornerRadii;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.stage.FileChooser;
import javafx.util.Duration;
import net.rptools.tokentool.AppConstants;
import net.rptools.tokentool.AppPreferences;
import net.rptools.tokentool.client.Credits;
import net.rptools.tokentool.client.ManageOverlays;
import net.rptools.tokentool.client.RegionSelector;
import net.rptools.tokentool.util.FileSaveUtil;
import net.rptools.tokentool.util.I18N;
import net.rptools.tokentool.util.ImageUtil;

public class TokenTool_Controller {
    @FXML
    private MenuItem fileManageOverlaysMenu;
    @FXML
    private MenuItem fileSaveAsMenu;
    @FXML
    private MenuItem fileExitMenu;

    @FXML
    private MenuItem editCaptureScreenMenu;
    @FXML
    private MenuItem editCopyImageMenu;
    @FXML
    private MenuItem editPasteImageMenu;

    @FXML
    private MenuItem helpAboutMenu;

    @FXML
    private TitledPane saveOptionsPane;
    @FXML
    private TitledPane overlayOptionsPane;
    @FXML
    private TitledPane backgroundOptionsPane;
    @FXML
    private TitledPane zoomOptionsPane;

    @FXML
    private StackPane compositeTokenPane;
    @FXML
    private BorderPane tokenPreviewPane;
    @FXML
    private ScrollPane portraitScrollPane;

    @FXML
    private Group compositeGroup;

    @FXML
    private TreeView<Path> overlayTreeView;

    @FXML
    private ImageView portraitImageView; // The bottom "Portrait" layer
    @FXML
    private ImageView maskImageView; // The mask layer used to crop the Portrait layer
    @FXML
    private ImageView overlayImageView; // The overlay layer to apply on top of everything
    @FXML
    private ImageView tokenImageView;

    @FXML
    private CheckBox useFileNumberingCheckbox;
    @FXML
    private CheckBox overlayUseAsBaseCheckbox;
    @FXML
    private CheckBox clipPortraitCheckbox;

    @FXML
    private TextField fileNameTextField;
    @FXML
    private Label fileNameSuffixLabel;
    @FXML
    private TextField fileNameSuffixTextField;
    @FXML
    private Label overlayNameLabel;
    @FXML
    private ColorPicker backgroundColorPicker;
    @FXML
    private ToggleButton overlayAspectToggleButton;

    @FXML
    private Slider portraitTransparencySlider;
    @FXML
    private Slider portraitBlurSlider;
    @FXML
    private Slider portraitGlowSlider;

    @FXML
    private Slider overlayTransparencySlider;

    @FXML
    private Spinner<Double> overlayWidthSpinner;
    @FXML
    private Spinner<Double> overlayHeightSpinner;

    @FXML
    private ProgressBar overlayTreeProgressBar;
    @FXML
    private Label progressBarLabel;

    private static final Logger log = LogManager.getLogger(TokenTool_Controller.class);

    private static ExecutorService executorService;
    private static Thread loadOverlaysThread = new Thread();
    private static AtomicInteger loadCount = new AtomicInteger(0);

    private static int overlayCount;

    private static TreeItem<Path> treeItems;
    private static TreeItem<Path> lastSelectedItem;
    private static TreeItem<Path> recentFolder = new TreeItem<>(
            new File(AppConstants.OVERLAY_DIR, "Recent").toPath(), null);

    private static Map<Path, TreeItem<Path>> recentOverlayTreeItems = new LinkedHashMap<Path, TreeItem<Path>>() {
        private static final long serialVersionUID = 2579964060760662199L;

        @Override
        protected boolean removeEldestEntry(Map.Entry<Path, TreeItem<Path>> eldest) {
            return size() > AppConstants.MAX_RECENT_SIZE;
        }
    };

    private Point dragStart = new Point();
    private Point portraitImageStart = new Point();
    private FileSaveUtil fileSaveUtil = new FileSaveUtil();

    @SuppressWarnings("unused")
    private RegionSelector regionSelector;

    // A custom set of Width/Height sizes to use for Overlays
    private NavigableSet<Double> overlaySpinnerSteps = new TreeSet<Double>(Arrays.asList(50d, 100d, 128d, 150d,
            200d, 256d, 300d, 400d, 500d, 512d, 600d, 700d, 750d, 800d, 900d, 1000d));

    @FXML
    void initialize() {
        // Note: A Pane is added to the compositeTokenPane so the ScrollPane doesn't consume the mouse events
        assert fileManageOverlaysMenu != null : "fx:id=\"fileManageOverlaysMenu\" was not injected: check your FXML file 'TokenTool.fxml'.";
        assert fileSaveAsMenu != null : "fx:id=\"fileSaveAsMenu\" was not injected: check your FXML file 'TokenTool.fxml'.";
        assert fileExitMenu != null : "fx:id=\"fileExitMenu\" was not injected: check your FXML file 'TokenTool.fxml'.";

        assert editCaptureScreenMenu != null : "fx:id=\"editCaptureScreenMenu\" was not injected: check your FXML file 'TokenTool.fxml'.";
        assert editCopyImageMenu != null : "fx:id=\"editCopyImageMenu\" was not injected: check your FXML file 'TokenTool.fxml'.";
        assert editPasteImageMenu != null : "fx:id=\"editPasteImageMenu\" was not injected: check your FXML file 'TokenTool.fxml'.";

        assert helpAboutMenu != null : "fx:id=\"helpAboutMenu\" was not injected: check your FXML file 'TokenTool.fxml'.";

        assert saveOptionsPane != null : "fx:id=\"saveOptionsPane\" was not injected: check your FXML file 'TokenTool.fxml'.";
        assert overlayOptionsPane != null : "fx:id=\"overlayOptionsPane\" was not injected: check your FXML file 'TokenTool.fxml'.";
        assert backgroundOptionsPane != null : "fx:id=\"backgroundOptionsPane\" was not injected: check your FXML file 'TokenTool.fxml'.";
        assert zoomOptionsPane != null : "fx:id=\"zoomOptionsPane\" was not injected: check your FXML file 'TokenTool.fxml'.";

        assert compositeTokenPane != null : "fx:id=\"compositeTokenPane\" was not injected: check your FXML file 'TokenTool.fxml'.";
        assert tokenPreviewPane != null : "fx:id=\"tokenPreviewPane\" was not injected: check your FXML file 'TokenTool.fxml'.";
        assert portraitScrollPane != null : "fx:id=\"portraitScrollPane\" was not injected: check your FXML file 'TokenTool.fxml'.";

        assert compositeGroup != null : "fx:id=\"compositeGroup\" was not injected: check your FXML file 'TokenTool.fxml'.";

        assert overlayTreeView != null : "fx:id=\"overlayTreeview\" was not injected: check your FXML file 'TokenTool.fxml'.";

        assert portraitImageView != null : "fx:id=\"portraitImageView\" was not injected: check your FXML file 'TokenTool.fxml'.";
        assert maskImageView != null : "fx:id=\"maskImageView\" was not injected: check your FXML file 'TokenTool.fxml'.";
        assert overlayImageView != null : "fx:id=\"overlayImageView\" was not injected: check your FXML file 'TokenTool.fxml'.";
        assert tokenImageView != null : "fx:id=\"tokenImageView\" was not injected: check your FXML file 'TokenTool.fxml'.";

        assert useFileNumberingCheckbox != null : "fx:id=\"useFileNumberingCheckbox\" was not injected: check your FXML file 'TokenTool.fxml'.";
        assert overlayUseAsBaseCheckbox != null : "fx:id=\"overlayUseAsBaseCheckbox\" was not injected: check your FXML file 'TokenTool.fxml'.";
        assert clipPortraitCheckbox != null : "fx:id=\"clipPortraitCheckbox\" was not injected: check your FXML file 'TokenTool.fxml'.";

        assert fileNameTextField != null : "fx:id=\"fileNameTextField\" was not injected: check your FXML file 'TokenTool.fxml'.";
        assert fileNameSuffixLabel != null : "fx:id=\"fileNameSuffixLabel\" was not injected: check your FXML file 'TokenTool.fxml'.";
        assert fileNameSuffixTextField != null : "fx:id=\"fileNameSuffixTextField\" was not injected: check your FXML file 'TokenTool.fxml'.";
        assert overlayNameLabel != null : "fx:id=\"overlayNameLabel\" was not injected: check your FXML file 'TokenTool.fxml'.";
        assert backgroundColorPicker != null : "fx:id=\"backgroundColorPicker\" was not injected: check your FXML file 'TokenTool.fxml'.";
        assert overlayAspectToggleButton != null : "fx:id=\"overlayAspectToggleButton\" was not injected: check your FXML file 'TokenTool.fxml'.";

        assert portraitTransparencySlider != null : "fx:id=\"portraitTransparencySlider\" was not injected: check your FXML file 'TokenTool.fxml'.";
        assert portraitBlurSlider != null : "fx:id=\"portraitBlurSlider\" was not injected: check your FXML file 'TokenTool.fxml'.";
        assert portraitGlowSlider != null : "fx:id=\"portraitGlowSlider\" was not injected: check your FXML file 'TokenTool.fxml'.";

        assert overlayTransparencySlider != null : "fx:id=\"overlayTransparencySlider\" was not injected: check your FXML file 'TokenTool.fxml'.";

        assert overlayWidthSpinner != null : "fx:id=\"overlayWidthSpinner\" was not injected: check your FXML file 'TokenTool.fxml'.";
        assert overlayHeightSpinner != null : "fx:id=\"overlayHeightSpinner\" was not injected: check your FXML file 'TokenTool.fxml'.";

        assert overlayTreeProgressBar != null : "fx:id=\"overlayTreeProgressIndicator\" was not injected: check your FXML file 'ManageOverlays.fxml'.";

        executorService = Executors.newCachedThreadPool(runable -> {
            loadOverlaysThread = Executors.defaultThreadFactory().newThread(runable);
            loadOverlaysThread.setDaemon(true);
            return loadOverlaysThread;
        });

        overlayTreeView.setShowRoot(false);
        overlayTreeView.getSelectionModel().selectedItemProperty().addListener(
                (observable, oldValue, newValue) -> updateCompositImageView((TreeItem<Path>) newValue));

        addPseudoClassToLeafs(overlayTreeView);

        // Bind color picker to compositeTokenPane background fill
        backgroundColorPicker.setValue(Color.TRANSPARENT);
        ObjectProperty<Background> background = compositeTokenPane.backgroundProperty();
        background.bind(Bindings.createObjectBinding(() -> {
            BackgroundFill fill = new BackgroundFill(backgroundColorPicker.getValue(), CornerRadii.EMPTY,
                    Insets.EMPTY);
            return new Background(fill);
        }, backgroundColorPicker.valueProperty()));

        // Bind transparency slider to portraitImageView opacity
        portraitTransparencySlider.valueProperty().addListener(new ChangeListener<Number>() {
            public void changed(ObservableValue<? extends Number> ov, Number old_val, Number new_val) {
                portraitImageView.setOpacity(new_val.doubleValue());
                updateTokenPreviewImageView();
            }
        });

        // // Restrict text field to valid filename characters
        // Pattern validDoubleText = Pattern.compile("[^a-zA-Z0-9\\\\._ \\\\/`~!@#$%\\\\^&\\\\(\\\\)\\\\-\\\\=\\\\+\\\\[\\\\]\\\\{\\\\}',\\\\\\\\:]");
        // Pattern validText = Pattern.compile("[^a-zA-Z0-9 ]");
        // TextFormatter<> textFormatter = new TextFormatter<>(
        // change -> {
        // String newText = change.getControlNewText();
        // if (validText.matcher(newText).matches()) {
        // return change;
        // } else
        // return null;
        // });

        // UnaryOperator<TextFormatter.Change> filter = new UnaryOperator<TextFormatter.Change>() {
        // @Override
        // public TextFormatter.Change apply(TextFormatter.Change t) {
        // String validText = "[^a-zA-Z0-9]";
        //
        // if (t.isReplaced())
        // if (t.getText().matches(validText))
        // t.setText(t.getControlText().substring(t.getRangeStart(), t.getRangeEnd()));
        //
        // if (t.isAdded()) {
        // if (t.getText().matches(validText)) {
        // return null;
        // }
        // }
        //
        // return t;
        // }
        // };

        UnaryOperator<Change> filter = change -> {
            String text = change.getText();

            if (text.matches(AppConstants.VALID_FILE_NAME_PATTERN)) {
                return change;
            } else {
                change.setText(FileSaveUtil.cleanFileName(text));
                ;
                return change;
            }
            //
            // return null;
        };
        TextFormatter<String> textFormatter = new TextFormatter<>(filter);
        fileNameTextField.setTextFormatter(textFormatter);

        // Effects
        GaussianBlur gaussianBlur = new GaussianBlur(0);
        Glow glow = new Glow(0);
        gaussianBlur.setInput(glow);

        // Bind blur slider to portraitImageView opacity
        portraitBlurSlider.valueProperty().addListener(new ChangeListener<Number>() {
            public void changed(ObservableValue<? extends Number> ov, Number old_val, Number new_val) {
                gaussianBlur.setRadius(new_val.doubleValue());
                portraitImageView.setEffect(gaussianBlur);
                updateTokenPreviewImageView();
            }
        });

        // Bind glow slider to portraitImageView opacity
        portraitGlowSlider.valueProperty().addListener(new ChangeListener<Number>() {
            public void changed(ObservableValue<? extends Number> ov, Number old_val, Number new_val) {
                glow.setLevel(new_val.doubleValue());
                portraitImageView.setEffect(gaussianBlur);
                updateTokenPreviewImageView();
            }
        });

        // Bind transparency slider to overlayImageView opacity
        overlayTransparencySlider.valueProperty().addListener(new ChangeListener<Number>() {
            public void changed(ObservableValue<? extends Number> ov, Number old_val, Number new_val) {
                overlayImageView.setOpacity(new_val.doubleValue());
                updateTokenPreviewImageView();
            }
        });

        // Bind width/height spinners to overlay width/height
        overlayWidthSpinner.getValueFactory().valueProperty()
                .bindBidirectional(overlayHeightSpinner.getValueFactory().valueProperty());
        overlayWidthSpinner.valueProperty().addListener(
                (observable, oldValue, newValue) -> overlayWidthSpinner_onTextChanged(oldValue, newValue));
        overlayHeightSpinner.valueProperty().addListener(
                (observable, oldValue, newValue) -> overlayHeightSpinner_onTextChanged(oldValue, newValue));
    }

    @FXML
    void removeBackgroundButton_onAction(ActionEvent event) {
        backgroundColorPicker.setValue(Color.TRANSPARENT);
        updateTokenPreviewImageView();
    }

    @FXML
    void fileManageOverlaysMenu_onAction(ActionEvent event) {
        @SuppressWarnings("unused")
        ManageOverlays manageOverlays = new ManageOverlays(this);
    }

    @FXML
    void fileSaveAsMenu_onAction(ActionEvent event) {
        saveToken();
    }

    @FXML
    void fileExitMenu_onAction(ActionEvent event) {
        exitApplication();
    }

    @FXML
    void editCaptureScreenMenu_onAction(ActionEvent event) {
        regionSelector = new RegionSelector(this);
    }

    @FXML
    void editCopyImageMenu_onAction(ActionEvent event) {
        Clipboard clipboard = Clipboard.getSystemClipboard();
        ClipboardContent content = new ClipboardContent();

        // for paste as file, e.g. in Windows Explorer
        try {
            File tempTokenFile = fileSaveUtil.getTempFileName(false, useFileNumberingCheckbox.isSelected(),
                    fileNameTextField.getText(), fileNameSuffixTextField);

            writeTokenImage(tempTokenFile);
            content.putFiles(java.util.Collections.singletonList(tempTokenFile));
            tempTokenFile.deleteOnExit();
        } catch (Exception e) {
            log.error(e);
        }

        // for paste as image, e.g. in GIMP
        content.putImage(tokenImageView.getImage());

        // Finally, put contents on clip board
        clipboard.setContent(content);
    }

    @FXML
    void editPasteImageMenu_onAction(ActionEvent event) {
        Clipboard clipboard = Clipboard.getSystemClipboard();
        Image originalImage = portraitImageView.getImage();

        // Strangely, we get an error if we try to paste an image we put in the clipboard ourselves but File works ok?
        // -Dprism.order=sw also fixes it but not sure why...
        // So lets just check for File first...
        if (clipboard.hasFiles()) {
            clipboard.getFiles().forEach(file -> {
                try {
                    Image cbImage = new Image(file.toURI().toURL().toExternalForm());

                    if (cbImage != null)
                        updatePortrait(cbImage);

                    updateFileNameTextField(FilenameUtils.getBaseName(file.toURI().toURL().toExternalForm()));
                } catch (Exception e) {
                    log.error("Could not load image " + file);
                    e.printStackTrace();
                }
            });
        } else if (clipboard.hasImage()) {
            try {
                Image cbImage = clipboard.getImage();
                if (cbImage != null)
                    updatePortrait(cbImage);
            } catch (IllegalArgumentException e) {
                log.info(e);
                updatePortrait(originalImage);
            }
        } else if (clipboard.hasUrl()) {
            try {
                Image cbImage = new Image(clipboard.getUrl());
                if (cbImage != null)
                    updatePortrait(cbImage);

                updateFileNameTextField(FileSaveUtil.searchURL(clipboard.getUrl()));
            } catch (IllegalArgumentException e) {
                log.info(e);
            }
        } else if (clipboard.hasString()) {
            try {
                Image cbImage = new Image(clipboard.getString());
                if (cbImage != null)
                    updatePortrait(cbImage);

                updateFileNameTextField(FileSaveUtil.searchURL(clipboard.getString()));
            } catch (IllegalArgumentException e) {
                log.info(e);
            }
        }
    }

    @FXML
    void helpAboutMenu_onAction(ActionEvent event) {
        @SuppressWarnings("unused")
        Credits credits = new Credits(this);
    }

    @FXML
    void useFileNumberingCheckbox_onAction(ActionEvent event) {
        fileNameSuffixLabel.setDisable(!useFileNumberingCheckbox.isSelected());
        fileNameSuffixTextField.setDisable(!useFileNumberingCheckbox.isSelected());
    }

    @FXML
    void compositeTokenPane_MouseDragged(MouseEvent event) {
        portraitImageView.setTranslateX(event.getX() - dragStart.x + portraitImageStart.x);
        portraitImageView.setTranslateY(event.getY() - dragStart.y + portraitImageStart.y);

        updateTokenPreviewImageView();
    }

    @FXML
    void compositeTokenPane_MousePressed(MouseEvent event) {
        dragStart.setLocation(event.getX(), event.getY());
        portraitImageStart.setLocation(portraitImageView.getTranslateX(), portraitImageView.getTranslateY());
        portraitImageView.setCursor(Cursor.MOVE);
    }

    @FXML
    void compositeTokenPane_MouseReleased(MouseEvent event) {
        portraitImageView.setCursor(Cursor.HAND);
        updateTokenPreviewImageView();
    }

    @FXML
    void compositeTokenPane_MouseEntered(MouseEvent event) {
        portraitImageView.setCursor(Cursor.HAND);
    }

    @FXML
    void compositeTokenPane_MouseDragExited(MouseDragEvent event) {
    }

    @FXML
    void compositeTokenPane_MouseExited(MouseEvent event) {
    }

    @FXML
    void compositeTokenPane_MouseMoved(MouseEvent event) {
    }

    @FXML
    void compositeTokenPane_OnScroll(ScrollEvent event) {
        // if event is touch enabled, skip this as it will be handled by onZoom & onRotate handlers
        if (event.isDirect())
            return;

        if (event.isShiftDown()) {
            // Note: OK, this is stupid but on Windows shift + mousewheel returns X delta but on Ubuntu it returns Y delta...
            double delta = event.getDeltaY();
            if (delta == 0)
                delta = event.getDeltaX();

            Double r = portraitImageView.getRotate() + delta / 20;

            if (r < -360d || r > 360d)
                r = 0d;

            portraitImageView.setRotate(r);
        } else {
            Double scale = portraitImageView.getScaleY() * Math.pow(1.001, event.getDeltaY());

            portraitImageView.setScaleX(scale);
            portraitImageView.setScaleY(scale);
        }

        event.consume();
        updateTokenPreviewImageView();
    }

    @FXML
    void compositeTokenPane_OnZoom(ZoomEvent event) {
        Double scale = portraitImageView.getScaleY() * event.getZoomFactor();

        portraitImageView.setScaleX(scale);
        portraitImageView.setScaleY(scale);
    }

    @FXML
    void compositeTokenPane_OnRotate(RotateEvent event) {
        log.info("isDirect(): " + event.isDirect());
        log.info("getTotalAngle" + event.getTotalAngle());

        double r = portraitImageView.getRotate() + (event.getAngle() * 0.75);
        if (r < -360d || r > 360d)
            r = 0d;

        portraitImageView.setRotate(r);
        event.consume();
    }

    @FXML
    void compositeTokenPane_DragDropped(DragEvent event) {
        Dragboard db = event.getDragboard();

        // Strangely, we get an error if we try to paste an image we put in the clipboard ourselves but File works ok?
        // -Dprism.order=sw also fixes it but not sure why...
        // So lets just check for File first...
        if (db.hasFiles()) {
            db.getFiles().forEach(file -> {
                try {
                    updateFileNameTextField(FilenameUtils.getBaseName(file.toURI().toURL().toExternalForm()));
                    updatePortrait(new Image(file.toURI().toURL().toExternalForm()));
                } catch (Exception e) {
                    log.error("Could not load image " + file, e);
                }
            });
            event.setDropCompleted(true);
        } else if (db.hasImage()) {
            updatePortrait(db.getImage());
            event.setDropCompleted(true);
        } else if (db.hasUrl()) {
            updateFileNameTextField(FileSaveUtil.searchURL(db.getUrl()));
            updatePortrait(new Image(db.getUrl()));
            event.setDropCompleted(true);
        }
    }

    @FXML
    void compositeTokenPane_DragDone(DragEvent event) {
        updateTokenPreviewImageView();
    }

    @FXML
    void compositeTokenPane_DragOver(DragEvent event) {
        if (event.getDragboard().hasImage() || event.getDragboard().hasFiles() || event.getDragboard().hasUrl()) {
            // Set Pane color to an alpha green
            event.acceptTransferModes(TransferMode.COPY);
        } else {
            // Set Pane color to an alpha red?
            event.acceptTransferModes(TransferMode.ANY);
        }
    }

    @FXML
    void tokenImageView_OnDragDetected(MouseEvent event) {
        Dragboard db = tokenImageView.startDragAndDrop(TransferMode.ANY);
        ClipboardContent content = new ClipboardContent();

        boolean saveAsToken = false;

        try {
            File tempTokenFile = fileSaveUtil.getTempFileName(saveAsToken, useFileNumberingCheckbox.isSelected(),
                    fileNameTextField.getText(), fileNameSuffixTextField);

            writeTokenImage(tempTokenFile);
            content.putFiles(java.util.Collections.singletonList(tempTokenFile));
            tempTokenFile.deleteOnExit();
        } catch (Exception e) {
            log.error(e);
        } finally {
            content.putImage(tokenImageView.getImage());
            db.setContent(content);
            event.consume();
        }
    }

    @FXML
    void tokenImageView_OnDragDone(DragEvent event) {
        if (event.getAcceptedTransferMode() != null)
            updateOverlayTreeViewRecentFolder(true);
    }

    @FXML
    void overlayUseAsBaseCheckbox_onAction(ActionEvent event) {
        if (overlayUseAsBaseCheckbox.isSelected())
            compositeGroup.toBack();
        else
            portraitScrollPane.toBack();

        updateTokenPreviewImageView();
    }

    @FXML
    void backgroundColorPicker_onAction(ActionEvent event) {
        updateTokenPreviewImageView();
    }

    @FXML
    void overlayAspectToggleButton_onAction(ActionEvent event) {
        if (overlayAspectToggleButton.isSelected()) {
            overlayImageView.setPreserveRatio(true);
            maskImageView.setPreserveRatio(true);
            overlayWidthSpinner.getValueFactory().valueProperty()
                    .bindBidirectional(overlayHeightSpinner.getValueFactory().valueProperty());
        } else {
            overlayImageView.setPreserveRatio(false);
            maskImageView.setPreserveRatio(false);
            overlayWidthSpinner.getValueFactory().valueProperty()
                    .unbindBidirectional(overlayHeightSpinner.getValueFactory().valueProperty());
        }

        updateTokenPreviewImageView();
    }

    void overlayWidthSpinner_onTextChanged(double oldValue, double newValue) {
        if (newValue < overlaySpinnerSteps.first())
            newValue = overlaySpinnerSteps.first();

        if (newValue > overlaySpinnerSteps.last())
            newValue = overlaySpinnerSteps.last();

        if (newValue > oldValue)
            overlayWidthSpinner.getValueFactory().setValue(overlaySpinnerSteps.ceiling(newValue));
        else
            overlayWidthSpinner.getValueFactory().setValue(overlaySpinnerSteps.floor(newValue));

        overlayImageView.setFitWidth(overlayWidthSpinner.getValue());
        maskImageView.setFitWidth(overlayWidthSpinner.getValue());

        updateTokenPreviewImageView();
    }

    void overlayHeightSpinner_onTextChanged(double oldValue, double newValue) {
        if (newValue < overlaySpinnerSteps.first())
            newValue = overlaySpinnerSteps.first();

        if (newValue > overlaySpinnerSteps.last())
            newValue = overlaySpinnerSteps.last();

        if (newValue > oldValue)
            overlayHeightSpinner.getValueFactory().setValue(overlaySpinnerSteps.ceiling(newValue));
        else
            overlayHeightSpinner.getValueFactory().setValue(overlaySpinnerSteps.floor(newValue));

        overlayImageView.setFitHeight(overlayHeightSpinner.getValue());
        maskImageView.setFitHeight(overlayHeightSpinner.getValue());

        updateTokenPreviewImageView();
    }

    public Map<Path, TreeItem<Path>> getRecentOverlayTreeItems() {
        return recentOverlayTreeItems;
    }

    public void updateRecentOverlayTreeItems(Path filePath) {
        try {
            TreeItem<Path> recentOverlay = new TreeItem<Path>(filePath,
                    ImageUtil.getOverlayThumb(new ImageView(), filePath));

            // Remove first so if it is on the list it forces to top of list
            recentOverlayTreeItems.remove(filePath);
            recentOverlayTreeItems.put(filePath, recentOverlay);
        } catch (IOException e) {
            log.error("Error loading recent overlay preference for " + filePath.toString());
        }
    }

    public void expandOverlayOptionsPane(boolean expand) {
        overlayOptionsPane.setExpanded(expand);
    }

    public void expandBackgroundOptionsPane(boolean expand) {
        backgroundOptionsPane.setExpanded(expand);
    }

    public void updateOverlayTreeview(TreeItem<Path> overlayTreeItems) {
        overlayTreeView.setRoot(overlayTreeItems);
    }

    public void updateTokenPreviewImageView() {
        tokenImageView.setImage(ImageUtil.composePreview(compositeTokenPane, backgroundColorPicker.getValue(),
                portraitImageView, maskImageView, overlayImageView, overlayUseAsBaseCheckbox.isSelected(),
                clipPortraitCheckbox.isSelected()));
        tokenImageView.setPreserveRatio(true);
    }

    private void saveToken() {
        FileChooser fileChooser = new FileChooser();

        try {
            File tokenFile = fileSaveUtil.getFileName(false, useFileNumberingCheckbox.isSelected(),
                    fileNameTextField.getText(), fileNameSuffixTextField);
            fileChooser.setInitialFileName(tokenFile.getName());
            if (tokenFile.getParentFile() != null)
                if (tokenFile.getParentFile().isDirectory())
                    fileChooser.setInitialDirectory(tokenFile.getParentFile());
        } catch (IOException e1) {
            log.error("Error writing token!", e1);
        }

        fileChooser.getExtensionFilters().addAll(AppConstants.IMAGE_EXTENSION_FILTER);
        fileChooser.setTitle(I18N.getString("TokenTool.save.filechooser.title"));
        fileChooser.setSelectedExtensionFilter(AppConstants.IMAGE_EXTENSION_FILTER);

        File tokenSaved = fileChooser.showSaveDialog(saveOptionsPane.getScene().getWindow());

        if (tokenSaved == null)
            return;

        writeTokenImage(tokenSaved);

        updateFileNameTextField(FilenameUtils.getBaseName(tokenSaved.getName()));
        FileSaveUtil.setLastFile(tokenSaved);
        updateOverlayTreeViewRecentFolder(true);
    }

    private boolean writeTokenImage(File tokenFile) {
        try {
            Image tokenImage;
            if (clipPortraitCheckbox.isSelected())
                tokenImage = ImageUtil.resizeCanvas(tokenImageView.getImage(), getOverlayWidth(),
                        getOverlayHeight());
            else
                tokenImage = tokenImageView.getImage();

            return ImageIO.write(SwingFXUtils.fromFXImage(tokenImage, null), "png", tokenFile);
        } catch (IOException e) {
            log.error("Unable to write token to file: " + tokenFile.getAbsolutePath(), e);
        } catch (IndexOutOfBoundsException e) {
            log.error("Image width/height out of bounds: " + getOverlayWidth() + " x " + getOverlayHeight(), e);
        }

        return false;
    }

    public void updateOverlayTreeViewRecentFolder(boolean selectMostRecent) {
        if (lastSelectedItem != null)
            updateRecentOverlayTreeItems(lastSelectedItem.getValue());

        // Update Recent Overlay List
        if (!recentOverlayTreeItems.isEmpty()) {
            // Remember current selection (adding/removing tree items messes with the selection model)
            // int selectedItem = overlayTreeView.getSelectionModel().getSelectedIndex();
            overlayTreeView.getSelectionModel().clearSelection();

            // Clear current folder
            recentFolder.getChildren().clear();

            // Add recent list to recentFolder in reverse order so most recent is at the top
            ListIterator<Entry<Path, TreeItem<Path>>> iter = new ArrayList<>(recentOverlayTreeItems.entrySet())
                    .listIterator(recentOverlayTreeItems.size());
            while (iter.hasPrevious())
                recentFolder.getChildren().add(iter.previous().getValue());

            if (overlayTreeView.getRoot().getChildren().indexOf(recentFolder) == -1) {
                overlayTreeView.getRoot().getChildren().add(recentFolder);
            } else {
                overlayTreeView.getRoot().getChildren().remove(recentFolder);
                overlayTreeView.getRoot().getChildren().add(recentFolder);
            }

            // Auto expand recent folder...
            recentFolder.setExpanded(true);

            addPseudoClassToLeafs(overlayTreeView);

            // Set the selected index back to what it was unless...
            if (selectMostRecent) {
                overlayTreeView.getSelectionModel().select(recentFolder.getChildren().get(0));
            } else {
                // overlayTreeView.getSelectionModel().clearAndSelect(selectedItem);
            }

        }
    }

    private void addPseudoClassToLeafs(TreeView<Path> tree) {
        PseudoClass leaf = PseudoClass.getPseudoClass("leaf");

        tree.setCellFactory(tv -> {
            TreeCell<Path> cell = new TreeCell<>();
            cell.itemProperty().addListener((obs, oldValue, newValue) -> {
                if (newValue == null) {
                    cell.setText("");
                    cell.setGraphic(null);
                } else {
                    cell.setText(newValue.toFile().getName());
                    cell.setGraphic(cell.getTreeItem().getGraphic());
                }
            });
            cell.treeItemProperty().addListener((obs, oldTreeItem, newTreeItem) -> cell
                    .pseudoClassStateChanged(leaf, newTreeItem != null && newTreeItem.isLeaf()));
            return cell;
        });
    }

    public void updatePortrait(Image newPortraitImage) {
        double w = newPortraitImage.getWidth();
        double h = newPortraitImage.getHeight();
        double pw = portraitScrollPane.getWidth();
        double ph = portraitScrollPane.getHeight();

        portraitImageView.setImage(newPortraitImage);

        portraitImageView.setTranslateX((pw - w) / 2);
        portraitImageView.setTranslateY((ph - h) / 2);
        portraitImageView.setScaleX(1);
        portraitImageView.setScaleY(1);
        portraitImageView.setRotate(0d);

        updateTokenPreviewImageView();
    }

    private void updateCompositImageView(TreeItem<Path> treeNode) {
        // Node removed...
        if (treeNode == null)
            return;

        // I'm not a leaf on the wind! (Sub directory node)
        if (treeNode.getChildren().size() > 0)
            return;

        try {
            Path filePath = treeNode.getValue();
            lastSelectedItem = treeNode;

            // Set the Image Views
            maskImageView = ImageUtil.getMaskImage(maskImageView, filePath);
            overlayImageView = ImageUtil.getOverlayImage(overlayImageView, filePath);

            // Set the text label
            overlayNameLabel.setText(FilenameUtils.getBaseName(filePath.toFile().getName()));

            updateTokenPreviewImageView();
        } catch (IOException e) {
            // Not a valid URL, most likely this is just because it's a directory node.
            e.printStackTrace();
        }
    }

    public Color getBackgroundColor() {
        return backgroundColorPicker.getValue();
    }

    public void setBackgroundColor(Color newColor) {
        backgroundColorPicker.setValue(newColor);
    }

    public void refreshCache() {
        overlayTreeProgressBar.setStyle("");
        overlayTreeProgressBar.setVisible(true);
        overlayTreeProgressBar.setOpacity(1.0);
        overlayNameLabel.setOpacity(0.0);
        progressBarLabel.setVisible(true);
        updateOverlayTreeview(null);

        try {
            loadCount.set(0);
            overlayCount = (int) Files.walk(AppConstants.OVERLAY_DIR.toPath()).filter(Files::isRegularFile).count();
            log.info("overlayCount: " + overlayCount);

            treeItems = cacheOverlays(AppConstants.OVERLAY_DIR, null, AppConstants.THUMB_SIZE);
        } catch (IOException e) {
            log.error("Error reloading overlay cache!", e);
        }
    }

    private void treeViewFinish() {
        log.info("***treeViewFinish called");
        // Sort the nodes off of root
        treeItems = sortTreeNodes(treeItems);

        updateOverlayTreeview(treeItems);
        addPseudoClassToLeafs(overlayTreeView);
        updateOverlayTreeViewRecentFolder(false);

        // overlayNameLabel.setVisible(true);
        overlayTreeProgressBar.setStyle("-fx-accent: forestgreen;");
        progressBarLabel.setVisible(false);

        FadeTransition fadeOut = new FadeTransition(Duration.millis(2000));
        fadeOut.setNode(overlayTreeProgressBar);
        fadeOut.setFromValue(1.0);
        fadeOut.setToValue(0.0);
        fadeOut.setCycleCount(1);
        fadeOut.setAutoReverse(false);
        fadeOut.playFromStart();

        FadeTransition fadeIn = new FadeTransition(Duration.millis(4000));
        fadeIn.setNode(overlayNameLabel);
        fadeIn.setFromValue(0.0);
        fadeIn.setToValue(1.0);
        fadeIn.setCycleCount(1);
        fadeIn.setAutoReverse(false);
        fadeIn.playFromStart();

    }

    private TreeItem<Path> cacheOverlays(File dir, TreeItem<Path> parent, int THUMB_SIZE) throws IOException {
        log.info("Caching " + dir.getAbsolutePath());

        TreeItem<Path> root = new TreeItem<>(dir.toPath());
        root.setExpanded(false);
        File[] files = dir.listFiles();
        final String I18N_CACHE_TEXT = I18N.getString("TokenTool.treeview.caching");

        final Task<Void> task = new Task<Void>() {
            @Override
            protected Void call() throws Exception {
                for (File file : files) {
                    if (loadOverlaysThread.isInterrupted())
                        break;

                    if (file.isDirectory()) {
                        cacheOverlays(file, root, THUMB_SIZE);
                    } else {
                        Path filePath = file.toPath();
                        TreeItem<Path> imageNode = new TreeItem<>(filePath,
                                ImageUtil.getOverlayThumb(new ImageView(), filePath));
                        root.getChildren().add(imageNode);
                        loadCount.getAndIncrement();
                        overlayTreeProgressBar.progressProperty().set(loadCount.doubleValue() / overlayCount);
                    }
                }

                if (parent != null) {
                    // When we show the overlay image, the TreeItem value is empty so we need to
                    // sort those to the bottom for a cleaner look and keep sub dir's at the top.
                    // If a node has no children then it's an overlay, otherwise it's a directory...
                    root.getChildren().sort(new Comparator<TreeItem<Path>>() {
                        @Override
                        public int compare(TreeItem<Path> o1, TreeItem<Path> o2) {
                            if (o1.getChildren().size() == 0 && o2.getChildren().size() == 0)
                                return 0;
                            else if (o1.getChildren().size() == 0)
                                return Integer.MAX_VALUE;
                            else if (o2.getChildren().size() == 0)
                                return Integer.MIN_VALUE;
                            else
                                return o1.getValue().compareTo(o2.getValue());
                        }
                    });

                    parent.getChildren().add(root);

                    parent.getChildren().sort(new Comparator<TreeItem<Path>>() {
                        @Override
                        public int compare(TreeItem<Path> o1, TreeItem<Path> o2) {
                            if (o1.getChildren().size() == 0 && o2.getChildren().size() == 0)
                                return 0;
                            else if (o1.getChildren().size() == 0)
                                return Integer.MAX_VALUE;
                            else if (o2.getChildren().size() == 0)
                                return Integer.MIN_VALUE;
                            else
                                return o1.getValue().compareTo(o2.getValue());
                        }
                    });
                }

                return null;
            }
        };

        overlayTreeProgressBar.progressProperty().addListener(observable -> {
            Platform.runLater(() -> progressBarLabel
                    .setText(I18N_CACHE_TEXT + Math.round(overlayCount - loadCount.doubleValue()) + "..."));
        });

        // Only add this listener to the parent task so it's only called once
        if (parent == null) {
            overlayTreeProgressBar.progressProperty().addListener(observable -> {
                Platform.runLater(() -> {
                    if (overlayTreeProgressBar.getProgress() >= 1)
                        treeViewFinish();
                });
            });
        }

        executorService.execute(task);
        return root;
    }

    private TreeItem<Path> sortTreeNodes(TreeItem<Path> tree) {
        // Sort the nodes off of root
        tree.getChildren().sort(new Comparator<TreeItem<Path>>() {
            @Override
            public int compare(TreeItem<Path> o1, TreeItem<Path> o2) {
                if (o1.getChildren().size() == 0 && o2.getChildren().size() == 0)
                    return 0;
                else if (o1.getChildren().size() == 0)
                    return Integer.MAX_VALUE;
                else if (o2.getChildren().size() == 0)
                    return Integer.MIN_VALUE;
                else
                    return o1.getValue().compareTo(o2.getValue());
            }
        });

        return tree;
    }

    /*
     * getter/setter methods, mainly for user preferences
     */
    public double getOverlayWidth() {
        return overlayWidthSpinner.getValue();
    }

    public void setOverlayWidth(double newValue) {
        overlayWidthSpinner.getValueFactory().setValue(overlaySpinnerSteps.ceiling(newValue));
    }

    public double getOverlayHeight() {
        return overlayHeightSpinner.getValue();
    }

    public void setOverlayHeight(double newValue) {
        overlayHeightSpinner.getValueFactory().setValue(overlaySpinnerSteps.ceiling(newValue));
    }

    public boolean getOverlayAspect() {
        return overlayAspectToggleButton.isSelected();
    }

    public void setOverlayAspect(boolean selected) {
        // UI normally starts this toggle as selected == aspect locked
        if (!selected)
            overlayAspectToggleButton.fire();
    }

    public boolean getOverlayUseAsBase() {
        return overlayUseAsBaseCheckbox.isSelected();
    }

    public void setOverlayUseAsBase(boolean selected) {
        if (selected)
            overlayUseAsBaseCheckbox.fire();
    }

    public boolean getClipPortraitCheckbox() {
        return clipPortraitCheckbox.isSelected();
    }

    public void setClipPortraitCheckbox(boolean selected) {
        if (selected)
            clipPortraitCheckbox.fire();
    }

    public String getFileNameTextField() {
        return fileNameTextField.getText();
    }

    public void setFileNameTextField(String text) {
        fileNameTextField.setText(text);
    }

    public void updateFileNameTextField(String text) {
        if (!getUseFileNumberingCheckbox())
            if (text == null || text.isEmpty())
                fileNameTextField.setText(AppConstants.DEFAULT_TOKEN_NAME);
            else
                fileNameTextField.setText(FileSaveUtil.cleanFileName(text));
    }

    public boolean getUseFileNumberingCheckbox() {
        return useFileNumberingCheckbox.isSelected();
    }

    public void setUseFileNumberingCheckbox(boolean selected) {
        if (selected)
            useFileNumberingCheckbox.fire();
    }

    public String getFileNameSuffixTextField() {
        return fileNameSuffixTextField.getText();
    }

    public void setFileNameSuffixTextField(String text) {
        fileNameSuffixTextField.setText(text);
    }

    public Image getPortraitImage() {
        return portraitImageView.getImage();
    }

    public void setPortraitImage(Image newPortraitImage, double x, double y, double r, double s) {
        updatePortrait(newPortraitImage);
        portraitImageView.setTranslateX(x);
        portraitImageView.setTranslateY(y);
        portraitImageView.setRotate(r);
        portraitImageView.setScaleX(s);
        portraitImageView.setScaleY(s);
    }

    public void updatePortraitLocation(double xDelta, double yDelta) {
        if (xDelta != 0)
            portraitImageView.setTranslateX(portraitImageView.getTranslateX() + (xDelta / 2));

        if (yDelta != 0)
            portraitImageView.setTranslateY(portraitImageView.getTranslateY() + (yDelta / 2));
    }

    public ImageView getPortraitImageView() {
        return portraitImageView;
    }

    public void exitApplication() {
        try {
            AppPreferences.savePreferences(this);
        } catch (Exception e) {
            log.error("Error saving preferences!", e);
        } finally {
            log.info("Exiting application.");
            Platform.exit();
        }
    }
}