org.sleuthkit.autopsy.imagegallery.gui.drawableviews.GroupPane.java Source code

Java tutorial

Introduction

Here is the source code for org.sleuthkit.autopsy.imagegallery.gui.drawableviews.GroupPane.java

Source

/*
 * Autopsy Forensic Browser
 *
 * Copyright 2013-16 Basis Technology Corp.
 * Contact: carrier <at> sleuthkit <dot> org
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.sleuthkit.autopsy.imagegallery.gui.drawableviews;

import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Lists;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static java.util.Objects.isNull;
import static java.util.Objects.nonNull;
import java.util.Optional;
import java.util.logging.Level;
import java.util.stream.IntStream;
import javafx.animation.Interpolator;
import javafx.animation.KeyFrame;
import javafx.animation.KeyValue;
import javafx.animation.Timeline;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.BooleanBinding;
import javafx.beans.binding.DoubleBinding;
import javafx.beans.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ObservableValue;
import javafx.collections.ObservableList;
import javafx.collections.ObservableSet;
import javafx.event.ActionEvent;
import javafx.event.EventHandler;
import javafx.fxml.FXML;
import javafx.geometry.Bounds;
import javafx.scene.control.Button;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.Label;
import javafx.scene.control.MenuItem;
import javafx.scene.control.ScrollBar;
import javafx.scene.control.SplitMenuButton;
import javafx.scene.control.ToggleButton;
import javafx.scene.control.ToolBar;
import javafx.scene.effect.DropShadow;
import javafx.scene.input.KeyCode;
import static javafx.scene.input.KeyCode.DIGIT0;
import static javafx.scene.input.KeyCode.DIGIT1;
import static javafx.scene.input.KeyCode.DIGIT2;
import static javafx.scene.input.KeyCode.DIGIT3;
import static javafx.scene.input.KeyCode.DIGIT4;
import static javafx.scene.input.KeyCode.DIGIT5;
import static javafx.scene.input.KeyCode.DOWN;
import static javafx.scene.input.KeyCode.LEFT;
import static javafx.scene.input.KeyCode.NUMPAD0;
import static javafx.scene.input.KeyCode.NUMPAD1;
import static javafx.scene.input.KeyCode.NUMPAD2;
import static javafx.scene.input.KeyCode.NUMPAD3;
import static javafx.scene.input.KeyCode.NUMPAD4;
import static javafx.scene.input.KeyCode.NUMPAD5;
import static javafx.scene.input.KeyCode.RIGHT;
import static javafx.scene.input.KeyCode.UP;
import javafx.scene.input.KeyEvent;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Border;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.BorderStroke;
import javafx.scene.layout.BorderStrokeStyle;
import javafx.scene.layout.BorderWidths;
import javafx.scene.layout.CornerRadii;
import javafx.scene.layout.HBox;
import javafx.scene.paint.Color;
import javafx.util.Duration;
import javax.swing.Action;
import javax.swing.SwingUtilities;
import org.apache.commons.lang3.StringUtils;
import org.controlsfx.control.GridCell;
import org.controlsfx.control.GridView;
import org.controlsfx.control.SegmentedButton;
import org.controlsfx.control.action.ActionUtils;
import org.openide.util.Lookup;
import org.openide.util.NbBundle;
import org.openide.util.actions.Presenter;
import org.openide.windows.TopComponent;
import org.openide.windows.WindowManager;
import org.sleuthkit.autopsy.casemodule.Case;
import org.sleuthkit.autopsy.corecomponentinterfaces.ContextMenuActionsProvider;
import org.sleuthkit.autopsy.coreutils.Logger;
import org.sleuthkit.autopsy.coreutils.ThreadConfined;
import org.sleuthkit.autopsy.coreutils.ThreadConfined.ThreadType;
import org.sleuthkit.autopsy.directorytree.ExtractAction;
import org.sleuthkit.autopsy.imagegallery.FXMLConstructor;
import org.sleuthkit.autopsy.imagegallery.FileIDSelectionModel;
import org.sleuthkit.autopsy.imagegallery.ImageGalleryController;
import org.sleuthkit.autopsy.imagegallery.ImageGalleryTopComponent;
import org.sleuthkit.autopsy.imagegallery.actions.AddTagAction;
import org.sleuthkit.autopsy.imagegallery.actions.Back;
import org.sleuthkit.autopsy.imagegallery.actions.CategorizeAction;
import org.sleuthkit.autopsy.imagegallery.actions.CategorizeSelectedFilesAction;
import org.sleuthkit.autopsy.imagegallery.actions.Forward;
import org.sleuthkit.autopsy.imagegallery.actions.NextUnseenGroup;
import org.sleuthkit.autopsy.imagegallery.actions.RedoAction;
import org.sleuthkit.autopsy.imagegallery.actions.SwingMenuItemAdapter;
import org.sleuthkit.autopsy.imagegallery.actions.TagSelectedFilesAction;
import org.sleuthkit.autopsy.imagegallery.actions.UndoAction;
import org.sleuthkit.autopsy.imagegallery.datamodel.Category;
import org.sleuthkit.autopsy.imagegallery.datamodel.DrawableFile;
import org.sleuthkit.autopsy.imagegallery.datamodel.grouping.DrawableGroup;
import org.sleuthkit.autopsy.imagegallery.datamodel.grouping.GroupViewMode;
import org.sleuthkit.autopsy.imagegallery.datamodel.grouping.GroupViewState;
import org.sleuthkit.autopsy.imagegallery.gui.GuiUtils;
import org.sleuthkit.datamodel.TskCoreException;

/**
 * A GroupPane displays the contents of a {@link DrawableGroup}. It supports
 * both a {@link  GridView} based view and a {@link  SlideShowView} view by
 * swapping out its internal components.
 *
 *
 * TODO: Extract the The GridView instance to a separate class analogous to the
 * SlideShow.
 *
 * TODO: Move selection model into controlsfx GridView and submit pull request
 * to them.
 * https://bitbucket.org/controlsfx/controlsfx/issue/4/add-a-multipleselectionmodel-to-gridview
 */
public class GroupPane extends BorderPane {

    private static final Logger LOGGER = Logger.getLogger(GroupPane.class.getName());
    private static final BorderWidths BORDER_WIDTHS_2 = new BorderWidths(2);
    private static final CornerRadii CORNER_RADII_2 = new CornerRadii(2);

    private static final DropShadow DROP_SHADOW = new DropShadow(10, Color.BLUE);

    private static final Timeline flashAnimation = new Timeline(
            new KeyFrame(Duration.millis(400), new KeyValue(DROP_SHADOW.radiusProperty(), 1, Interpolator.LINEAR)),
            new KeyFrame(Duration.millis(400),
                    new KeyValue(DROP_SHADOW.radiusProperty(), 15, Interpolator.LINEAR)));

    private final FileIDSelectionModel selectionModel;
    private static final List<KeyCode> categoryKeyCodes = Arrays.asList(KeyCode.NUMPAD0, KeyCode.NUMPAD1,
            KeyCode.NUMPAD2, KeyCode.NUMPAD3, KeyCode.NUMPAD4, KeyCode.NUMPAD5, KeyCode.DIGIT0, KeyCode.DIGIT1,
            KeyCode.DIGIT2, KeyCode.DIGIT3, KeyCode.DIGIT4, KeyCode.DIGIT5);

    private final Back backAction;

    private final Forward forwardAction;

    @FXML
    private Button undoButton;
    @FXML
    private Button redoButton;

    @FXML
    private SplitMenuButton catSelectedSplitMenu;

    @FXML
    private SplitMenuButton tagSelectedSplitMenu;

    @FXML
    private ToolBar headerToolBar;

    @FXML
    private ToggleButton cat0Toggle;
    @FXML
    private ToggleButton cat1Toggle;
    @FXML
    private ToggleButton cat2Toggle;
    @FXML
    private ToggleButton cat3Toggle;
    @FXML
    private ToggleButton cat4Toggle;
    @FXML
    private ToggleButton cat5Toggle;

    @FXML
    private SegmentedButton segButton;

    private SlideShowView slideShowPane;

    @FXML
    private ToggleButton slideShowToggle;

    @FXML
    private GridView<Long> gridView;

    @FXML
    private ToggleButton tileToggle;

    @FXML
    private Button nextButton;

    @FXML
    private Button backButton;

    @FXML
    private Button forwardButton;

    @FXML
    private Label groupLabel;
    @FXML
    private Label bottomLabel;
    @FXML
    private Label headerLabel;
    @FXML
    private Label catContainerLabel;
    @FXML
    private Label catHeadingLabel;

    @FXML
    private HBox catSegmentedContainer;
    @FXML
    private HBox catSplitMenuContainer;

    private final KeyboardHandler tileKeyboardNavigationHandler = new KeyboardHandler();

    private final NextUnseenGroup nextGroupAction;

    private final ImageGalleryController controller;

    private ContextMenu contextMenu;

    private Integer selectionAnchorIndex;
    private final UndoAction undoAction;
    private final RedoAction redoAction;

    GroupViewMode getGroupViewMode() {
        return groupViewMode.get();
    }

    /**
     * the current GroupViewMode of this GroupPane
     */
    private final SimpleObjectProperty<GroupViewMode> groupViewMode = new SimpleObjectProperty<>(
            GroupViewMode.TILE);

    /**
     * the grouping this pane is currently the view for
     */
    private final ReadOnlyObjectWrapper<DrawableGroup> grouping = new ReadOnlyObjectWrapper<>();

    /**
     * map from fileIDs to their assigned cells in the tile view. This is used
     * to determine whether fileIDs are visible or are offscreen. No entry
     * indicates the given fileID is not displayed on screen. DrawableCells are
     * responsible for adding and removing themselves from this map.
     */
    @ThreadConfined(type = ThreadType.JFX)
    private final Map<Long, DrawableCell> cellMap = new HashMap<>();

    private final InvalidationListener filesSyncListener = (observable) -> {
        final String header = getHeaderString();
        final List<Long> fileIds = getGroup().getFileIDs();
        Platform.runLater(() -> {
            slideShowToggle.setDisable(fileIds.isEmpty());
            gridView.getItems().setAll(fileIds);
            groupLabel.setText(header);
        });
    };

    public GroupPane(ImageGalleryController controller) {
        this.controller = controller;
        this.selectionModel = controller.getSelectionModel();
        nextGroupAction = new NextUnseenGroup(controller);
        backAction = new Back(controller);
        forwardAction = new Forward(controller);
        undoAction = new UndoAction(controller);
        redoAction = new RedoAction(controller);

        FXMLConstructor.construct(this, "GroupPane.fxml"); //NON-NLS
    }

    @ThreadConfined(type = ThreadType.JFX)
    public void activateSlideShowViewer(Long slideShowFileID) {
        groupViewMode.set(GroupViewMode.SLIDE_SHOW);
        slideShowToggle.setSelected(true);
        //make a new slideShowPane if necessary
        if (slideShowPane == null) {
            slideShowPane = new SlideShowView(this, controller);
        }

        //assign last selected file or if none first file in group
        if (slideShowFileID == null || getGroup().getFileIDs().contains(slideShowFileID) == false) {
            slideShowPane.setFile(getGroup().getFileIDs().get(0));
        } else {
            slideShowPane.setFile(slideShowFileID);
        }

        setCenter(slideShowPane);
        slideShowPane.requestFocus();

    }

    void syncCatToggle(DrawableFile file) {
        getToggleForCategory(file.getCategory()).setSelected(true);
    }

    public void activateTileViewer() {
        groupViewMode.set(GroupViewMode.TILE);
        tileToggle.setSelected(true);
        setCenter(gridView);
        gridView.requestFocus();
        if (slideShowPane != null) {
            slideShowPane.disposeContent();
        }
        slideShowPane = null;
        this.scrollToFileID(selectionModel.lastSelectedProperty().get());
    }

    public DrawableGroup getGroup() {
        return grouping.get();
    }

    private void selectAllFiles() {
        selectionModel.clearAndSelectAll(getGroup().getFileIDs());
    }

    /**
     * create the string to display in the group header
     */
    @NbBundle.Messages({ "# {0} - default group name", "# {1} - hashset hits count", "# {2} - group size",
            "GroupPane.headerString={0} -- {1} hash set hits / {2} files" })
    protected String getHeaderString() {
        return isNull(getGroup()) ? ""
                : Bundle.GroupPane_headerString(
                        StringUtils.defaultIfBlank(getGroup().getGroupByValueDislpayName(),
                                DrawableGroup.getBlankGroupName()),
                        getGroup().getHashSetHitsCount(), getGroup().getSize());
    }

    ContextMenu getContextMenu() {
        return contextMenu;
    }

    ReadOnlyObjectProperty<DrawableGroup> grouping() {
        return grouping.getReadOnlyProperty();
    }

    private ToggleButton getToggleForCategory(Category category) {
        switch (category) {
        case ZERO:
            return cat0Toggle;
        case ONE:
            return cat1Toggle;
        case TWO:
            return cat2Toggle;
        case THREE:
            return cat3Toggle;
        case FOUR:
            return cat4Toggle;
        case FIVE:
            return cat5Toggle;
        default:
            throw new IllegalArgumentException(category.name());
        }
    }

    /**
     * called automatically during constructor by FXMLConstructor.
     *
     * checks that FXML loading went ok and performs additional setup
     */
    @FXML
    @NbBundle.Messages({ "GroupPane.gridViewContextMenuItem.extractFiles=Extract File(s)",
            "GroupPane.bottomLabel.displayText=Group Viewing History: ",
            "GroupPane.hederLabel.displayText=Tag Selected Files:",
            "GroupPane.catContainerLabel.displayText=Categorize Selected File:",
            "GroupPane.catHeadingLabel.displayText=Category:" })
    void initialize() {
        assert cat0Toggle != null : "fx:id=\"cat0Toggle\" was not injected: check your FXML file 'SlideShowView.fxml'.";
        assert cat1Toggle != null : "fx:id=\"cat1Toggle\" was not injected: check your FXML file 'SlideShowView.fxml'.";
        assert cat2Toggle != null : "fx:id=\"cat2Toggle\" was not injected: check your FXML file 'SlideShowView.fxml'.";
        assert cat3Toggle != null : "fx:id=\"cat3Toggle\" was not injected: check your FXML file 'SlideShowView.fxml'.";
        assert cat4Toggle != null : "fx:id=\"cat4Toggle\" was not injected: check your FXML file 'SlideShowView.fxml'.";
        assert cat5Toggle != null : "fx:id=\"cat5Toggle\" was not injected: check your FXML file 'SlideShowView.fxml'.";
        assert gridView != null : "fx:id=\"tilePane\" was not injected: check your FXML file 'GroupPane.fxml'.";
        assert catSelectedSplitMenu != null : "fx:id=\"grpCatSplitMenu\" was not injected: check your FXML file 'GroupHeader.fxml'.";
        assert tagSelectedSplitMenu != null : "fx:id=\"grpTagSplitMenu\" was not injected: check your FXML file 'GroupHeader.fxml'.";
        assert headerToolBar != null : "fx:id=\"headerToolBar\" was not injected: check your FXML file 'GroupHeader.fxml'.";
        assert segButton != null : "fx:id=\"previewList\" was not injected: check your FXML file 'GroupHeader.fxml'.";
        assert slideShowToggle != null : "fx:id=\"segButton\" was not injected: check your FXML file 'GroupHeader.fxml'.";
        assert tileToggle != null : "fx:id=\"tileToggle\" was not injected: check your FXML file 'GroupHeader.fxml'.";

        for (Category cat : Category.values()) {
            ToggleButton toggleForCategory = getToggleForCategory(cat);
            toggleForCategory.setBorder(new Border(
                    new BorderStroke(cat.getColor(), BorderStrokeStyle.SOLID, CORNER_RADII_2, BORDER_WIDTHS_2)));
            toggleForCategory.getStyleClass().remove("radio-button");
            toggleForCategory.getStyleClass().add("toggle-button");
            toggleForCategory.selectedProperty().addListener((ov, wasSelected, toggleSelected) -> {
                if (toggleSelected && slideShowPane != null) {
                    slideShowPane.getFileID().ifPresent(fileID -> {
                        selectionModel.clearAndSelect(fileID);
                        new CategorizeAction(controller, cat, ImmutableSet.of(fileID)).handle(null);
                    });
                }
            });
        }

        //configure flashing glow animation on next unseen group button
        flashAnimation.setCycleCount(Timeline.INDEFINITE);
        flashAnimation.setAutoReverse(true);

        //configure gridView cell properties
        DoubleBinding cellSize = controller.thumbnailSizeProperty().add(75);
        gridView.cellHeightProperty().bind(cellSize);
        gridView.cellWidthProperty().bind(cellSize);
        gridView.setCellFactory((GridView<Long> param) -> new DrawableCell());

        BooleanBinding isSelectionEmpty = Bindings.isEmpty(selectionModel.getSelected());
        catSelectedSplitMenu.disableProperty().bind(isSelectionEmpty);
        tagSelectedSplitMenu.disableProperty().bind(isSelectionEmpty);

        Platform.runLater(() -> {
            try {
                TagSelectedFilesAction followUpSelectedACtion = new TagSelectedFilesAction(
                        controller.getTagsManager().getFollowUpTagName(), controller);
                tagSelectedSplitMenu.setText(followUpSelectedACtion.getText());
                tagSelectedSplitMenu.setGraphic(followUpSelectedACtion.getGraphic());
                tagSelectedSplitMenu.setOnAction(followUpSelectedACtion);
            } catch (TskCoreException tskCoreException) {
                LOGGER.log(Level.WARNING, "failed to load FollowUpTagName", tskCoreException); //NON-NLS
            }
            tagSelectedSplitMenu.showingProperty().addListener(showing -> {
                if (tagSelectedSplitMenu.isShowing()) {
                    List<MenuItem> selTagMenues = Lists.transform(
                            controller.getTagsManager().getNonCategoryTagNames(),
                            tagName -> GuiUtils.createAutoAssigningMenuItem(tagSelectedSplitMenu,
                                    new TagSelectedFilesAction(tagName, controller)));
                    tagSelectedSplitMenu.getItems().setAll(selTagMenues);
                }
            });

        });

        CategorizeSelectedFilesAction cat5SelectedAction = new CategorizeSelectedFilesAction(Category.FIVE,
                controller);
        catSelectedSplitMenu.setOnAction(cat5SelectedAction);
        catSelectedSplitMenu.setText(cat5SelectedAction.getText());
        catSelectedSplitMenu.setGraphic(cat5SelectedAction.getGraphic());
        catSelectedSplitMenu.showingProperty().addListener(showing -> {
            if (catSelectedSplitMenu.isShowing()) {
                List<MenuItem> categoryMenues = Lists.transform(Arrays.asList(Category.values()),
                        cat -> GuiUtils.createAutoAssigningMenuItem(catSelectedSplitMenu,
                                new CategorizeSelectedFilesAction(cat, controller)));
                catSelectedSplitMenu.getItems().setAll(categoryMenues);
            }
        });

        slideShowToggle.getStyleClass().remove("radio-button");
        slideShowToggle.getStyleClass().add("toggle-button");
        tileToggle.getStyleClass().remove("radio-button");
        tileToggle.getStyleClass().add("toggle-button");

        bottomLabel.setText(Bundle.GroupPane_bottomLabel_displayText());
        headerLabel.setText(Bundle.GroupPane_hederLabel_displayText());
        catContainerLabel.setText(Bundle.GroupPane_catContainerLabel_displayText());
        catHeadingLabel.setText(Bundle.GroupPane_catHeadingLabel_displayText());
        //show categorization controls depending on group view mode
        headerToolBar.getItems().remove(catSegmentedContainer);
        groupViewMode.addListener((ObservableValue<? extends GroupViewMode> observable, GroupViewMode oldValue,
                GroupViewMode newValue) -> {
            if (newValue == GroupViewMode.SLIDE_SHOW) {
                headerToolBar.getItems().remove(catSplitMenuContainer);
                headerToolBar.getItems().add(catSegmentedContainer);
            } else {
                headerToolBar.getItems().remove(catSegmentedContainer);
                headerToolBar.getItems().add(catSplitMenuContainer);
            }
        });

        //listen to toggles and update view state
        slideShowToggle
                .setOnAction(onAction -> activateSlideShowViewer(selectionModel.lastSelectedProperty().get()));
        tileToggle.setOnAction(onAction -> activateTileViewer());

        controller.viewState().addListener((observable, oldViewState, newViewState) -> setViewState(newViewState));

        addEventFilter(KeyEvent.KEY_PRESSED, tileKeyboardNavigationHandler);
        gridView.addEventHandler(MouseEvent.MOUSE_CLICKED, new MouseHandler());

        ActionUtils.configureButton(undoAction, undoButton);
        ActionUtils.configureButton(redoAction, redoButton);
        ActionUtils.configureButton(forwardAction, forwardButton);
        ActionUtils.configureButton(backAction, backButton);
        ActionUtils.configureButton(nextGroupAction, nextButton);
        /*
         * the next button does stuff in the GroupPane that next action does'nt
         * know about, so do that stuff and then delegate to nextGroupAction
         */
        final EventHandler<ActionEvent> onAction = nextButton.getOnAction();
        nextButton.setOnAction(actionEvent -> {
            flashAnimation.stop();
            nextButton.setEffect(null);
            onAction.handle(actionEvent);
        });

        nextGroupAction.disabledProperty().addListener((Observable observable) -> {
            boolean newValue = nextGroupAction.isDisabled();
            nextButton.setEffect(newValue ? null : DROP_SHADOW);
            if (newValue) {//stop on disabled
                flashAnimation.stop();
            } else { //play when enabled
                flashAnimation.play();
            }
        });

        //listen to tile selection and make sure it is visible in scroll area
        selectionModel.lastSelectedProperty().addListener((observable, oldFileID, newFileId) -> {
            if (groupViewMode.get() == GroupViewMode.SLIDE_SHOW && slideShowPane != null) {
                slideShowPane.setFile(newFileId);
            } else {
                scrollToFileID(newFileId);
            }
        });

        setViewState(controller.viewState().get());
    }

    //TODO: make sure we are testing complete visability not just bounds intersection
    @ThreadConfined(type = ThreadType.JFX)
    private void scrollToFileID(final Long newFileID) {
        if (newFileID == null) {
            return; //scrolling to no file doesn't make sense, so abort.
        }

        final ObservableList<Long> fileIds = gridView.getItems();

        int selectedIndex = fileIds.indexOf(newFileID);
        if (selectedIndex == -1) {
            //somehow we got passed a file id that isn't in the curent group.
            //this should never happen, but if it does everything is going to fail, so abort.
            return;
        }

        getScrollBar().ifPresent(scrollBar -> {
            DrawableCell cell = cellMap.get(newFileID);

            //while there is no tile/cell for the given id, scroll based on index in group
            while (isNull(cell)) {
                //TODO:  can we maintain a cached mapping from fileID-> index to speed up performance
                //get the min and max index of files that are in the cellMap
                Integer minIndex = cellMap.keySet().stream().mapToInt(fileID -> fileIds.indexOf(fileID)).min()
                        .getAsInt();
                Integer maxIndex = cellMap.keySet().stream().mapToInt(fileID -> fileIds.indexOf(fileID)).max()
                        .getAsInt();

                //[minIndex, maxIndex] is the range of indexes in the fileIDs list that are currently displayed
                if (selectedIndex < minIndex) {
                    scrollBar.decrement();
                } else if (selectedIndex > maxIndex) {
                    scrollBar.increment();
                } else {
                    //sometimes the cellMap isn't up to date, so move the position arbitrarily to update the cellMap
                    //TODO: this is clunky and slow, find a better way to do this
                    scrollBar.adjustValue(.5);
                }
                cell = cellMap.get(newFileID);
            }

            final Bounds gridViewBounds = gridView.localToScene(gridView.getBoundsInLocal());
            Bounds tileBounds = cell.localToScene(cell.getBoundsInLocal());

            //while the cell is not within the visisble bounds of the gridview, scroll based on screen coordinates
            int i = 0;
            while (gridViewBounds.contains(tileBounds) == false && (i++ < 100)) {

                if (tileBounds.getMinY() < gridViewBounds.getMinY()) {
                    scrollBar.decrement();
                } else if (tileBounds.getMaxY() > gridViewBounds.getMaxY()) {
                    scrollBar.increment();
                }
                tileBounds = cell.localToScene(cell.getBoundsInLocal());
            }
        });
    }

    /**
     * assigns a grouping for this pane to represent and initializes grouping
     * specific properties and listeners
     *
     * @param grouping the new grouping assigned to this group
     */
    void setViewState(GroupViewState viewState) {

        if (isNull(viewState) || isNull(viewState.getGroup())) {
            if (nonNull(getGroup())) {
                getGroup().getFileIDs().removeListener(filesSyncListener);
            }
            this.grouping.set(null);

            Platform.runLater(() -> {
                gridView.getItems().setAll(Collections.emptyList());
                setCenter(null);
                slideShowToggle.setDisable(true);
                groupLabel.setText("");
                resetScrollBar();
                if (false == Case.isCaseOpen()) {
                    cellMap.values().stream().forEach(DrawableCell::resetItem);
                    cellMap.clear();
                }
            });

        } else {
            if (getGroup() != viewState.getGroup()) {
                if (nonNull(getGroup())) {
                    getGroup().getFileIDs().removeListener(filesSyncListener);
                }
                this.grouping.set(viewState.getGroup());

                getGroup().getFileIDs().addListener(filesSyncListener);

                final String header = getHeaderString();

                Platform.runLater(() -> {
                    gridView.getItems().setAll(getGroup().getFileIDs());
                    slideShowToggle.setDisable(gridView.getItems().isEmpty());
                    groupLabel.setText(header);
                    resetScrollBar();
                    if (viewState.getMode() == GroupViewMode.TILE) {
                        activateTileViewer();
                    } else {
                        activateSlideShowViewer(viewState.getSlideShowfileID().orElse(null));
                    }
                });
            }
        }
    }

    @ThreadConfined(type = ThreadType.JFX)
    private void resetScrollBar() {
        getScrollBar().ifPresent((scrollBar) -> {
            scrollBar.setValue(0);
        });
    }

    @ThreadConfined(type = ThreadType.JFX)
    private Optional<ScrollBar> getScrollBar() {
        if (gridView == null || gridView.getSkin() == null) {
            return Optional.empty();
        }
        return Optional.ofNullable((ScrollBar) gridView.getSkin().getNode().lookup(".scroll-bar")); //NON-NLS
    }

    void makeSelection(Boolean shiftDown, Long newFileID) {

        if (shiftDown) {
            //TODO: do more hear to implement slicker multiselect
            int endIndex = grouping.get().getFileIDs().indexOf(newFileID);
            int startIndex = IntStream.of(grouping.get().getFileIDs().size(), selectionAnchorIndex, endIndex).min()
                    .getAsInt();
            endIndex = IntStream.of(0, selectionAnchorIndex, endIndex).max().getAsInt();
            List<Long> subList = grouping.get().getFileIDs().subList(Math.max(0, startIndex),
                    Math.min(endIndex, grouping.get().getFileIDs().size()) + 1);

            selectionModel.clearAndSelectAll(subList.toArray(new Long[subList.size()]));
            selectionModel.select(newFileID);
        } else {
            selectionAnchorIndex = null;
            selectionModel.clearAndSelect(newFileID);
        }
    }

    private class DrawableCell extends GridCell<Long> {

        private final DrawableTile tile = new DrawableTile(GroupPane.this, controller);

        DrawableCell() {
            itemProperty()
                    .addListener((ObservableValue<? extends Long> observable, Long oldValue, Long newValue) -> {
                        if (oldValue != null) {
                            cellMap.remove(oldValue, DrawableCell.this);
                            tile.setFile(null);
                        }
                        if (newValue != null) {
                            if (cellMap.containsKey(newValue)) {
                                if (tile != null) {
                                    // Clear out the old value to prevent out-of-date listeners
                                    // from activating.
                                    cellMap.get(newValue).tile.setFile(null);
                                }
                            }
                            cellMap.put(newValue, DrawableCell.this);

                        }
                    });

            setGraphic(tile);
        }

        @Override
        protected void updateItem(Long item, boolean empty) {
            super.updateItem(item, empty);
            tile.setFile(item);
        }

        void resetItem() {
            tile.setFile(null);
        }
    }

    /**
     * implements the key handler for tile navigation ( up, down , left, right
     * arrows)
     */
    private class KeyboardHandler implements EventHandler<KeyEvent> {

        @Override
        public void handle(KeyEvent t) {

            if (t.getEventType() == KeyEvent.KEY_PRESSED) {
                switch (t.getCode()) {
                case SHIFT:
                    if (selectionAnchorIndex == null) {
                        selectionAnchorIndex = grouping.get().getFileIDs()
                                .indexOf(selectionModel.lastSelectedProperty().get());
                    }
                    t.consume();
                    break;
                case UP:
                case DOWN:
                case LEFT:
                case RIGHT:
                    if (groupViewMode.get() == GroupViewMode.TILE) {
                        handleArrows(t);
                        t.consume();
                    }
                    break;
                case PAGE_DOWN:
                    getScrollBar().ifPresent((scrollBar) -> {
                        scrollBar.adjustValue(1);
                    });
                    t.consume();
                    break;
                case PAGE_UP:
                    getScrollBar().ifPresent((scrollBar) -> {
                        scrollBar.adjustValue(0);
                    });
                    t.consume();
                    break;
                case ENTER:
                    nextGroupAction.handle(null);
                    t.consume();
                    break;
                case SPACE:
                    if (groupViewMode.get() == GroupViewMode.TILE) {
                        activateSlideShowViewer(selectionModel.lastSelectedProperty().get());
                    } else {
                        activateTileViewer();
                    }
                    t.consume();
                    break;
                }

                if (groupViewMode.get() == GroupViewMode.TILE && categoryKeyCodes.contains(t.getCode())
                        && t.isAltDown()) {
                    selectAllFiles();
                    t.consume();
                }
                ObservableSet<Long> selected = selectionModel.getSelected();
                if (selected.isEmpty() == false) {
                    Category cat = keyCodeToCat(t.getCode());
                    if (cat != null) {
                        new CategorizeAction(controller, cat, selected).handle(null);
                    }
                }
            }
        }

        private Category keyCodeToCat(KeyCode t) {
            if (t != null) {
                switch (t) {
                case NUMPAD0:
                case DIGIT0:
                    return Category.ZERO;
                case NUMPAD1:
                case DIGIT1:
                    return Category.ONE;
                case NUMPAD2:
                case DIGIT2:
                    return Category.TWO;
                case NUMPAD3:
                case DIGIT3:
                    return Category.THREE;
                case NUMPAD4:
                case DIGIT4:
                    return Category.FOUR;
                case NUMPAD5:
                case DIGIT5:
                    return Category.FIVE;
                }
            }
            return null;
        }

        private void handleArrows(KeyEvent t) {
            Long lastSelectFileId = selectionModel.lastSelectedProperty().get();

            int lastSelectedIndex = lastSelectFileId != null ? grouping.get().getFileIDs().indexOf(lastSelectFileId)
                    : Optional.ofNullable(selectionAnchorIndex).orElse(0);

            final int columns = Math.max((int) Math.floor((gridView.getWidth() - 18)
                    / (gridView.getCellWidth() + gridView.getHorizontalCellSpacing() * 2)), 1);

            final Map<KeyCode, Integer> tileIndexMap = ImmutableMap.of(UP, -columns, DOWN, columns, LEFT, -1, RIGHT,
                    1);

            // implement proper keyboard based multiselect
            int indexOfToBeSelectedTile = lastSelectedIndex + tileIndexMap.get(t.getCode());
            final int size = grouping.get().getFileIDs().size();
            if (0 > indexOfToBeSelectedTile) {
                //don't select past begining of group
            } else if (0 <= indexOfToBeSelectedTile && indexOfToBeSelectedTile < size) {
                //normal selection within group
                makeSelection(t.isShiftDown(), grouping.get().getFileIDs().get(indexOfToBeSelectedTile));
            } else if (indexOfToBeSelectedTile <= size - 1 + columns - (size % columns)) {
                //selection last item if selection is empty space at end of group
                makeSelection(t.isShiftDown(), grouping.get().getFileIDs().get(size - 1));
            } else {
                //don't select past end of group
            }
        }
    }

    private class MouseHandler implements EventHandler<MouseEvent> {

        private ContextMenu buildContextMenu() {
            ArrayList<MenuItem> menuItems = new ArrayList<>();

            menuItems.add(CategorizeAction.getCategoriesMenu(controller));
            menuItems.add(AddTagAction.getTagMenu(controller));

            Collection<? extends ContextMenuActionsProvider> menuProviders = Lookup.getDefault()
                    .lookupAll(ContextMenuActionsProvider.class);

            for (ContextMenuActionsProvider provider : menuProviders) {
                for (final Action act : provider.getActions()) {
                    if (act instanceof Presenter.Popup) {
                        Presenter.Popup aact = (Presenter.Popup) act;
                        menuItems.add(SwingMenuItemAdapter.create(aact.getPopupPresenter()));
                    }
                }
            }
            final MenuItem extractMenuItem = new MenuItem(Bundle.GroupPane_gridViewContextMenuItem_extractFiles());
            extractMenuItem.setOnAction((ActionEvent t) -> {
                SwingUtilities.invokeLater(() -> {
                    TopComponent etc = WindowManager.getDefault()
                            .findTopComponent(ImageGalleryTopComponent.PREFERRED_ID);
                    ExtractAction.getInstance().actionPerformed(new java.awt.event.ActionEvent(etc, 0, null));
                });
            });
            menuItems.add(extractMenuItem);

            ContextMenu contextMenu = new ContextMenu(menuItems.toArray(new MenuItem[] {}));
            contextMenu.setAutoHide(true);
            return contextMenu;
        }

        @Override
        public void handle(MouseEvent t) {
            switch (t.getButton()) {
            case PRIMARY:
                if (t.getClickCount() == 1) {
                    selectionModel.clearSelection();
                    if (contextMenu != null) {
                        contextMenu.hide();
                    }
                }
                t.consume();
                break;
            case SECONDARY:
                if (t.getClickCount() == 1) {
                    selectAllFiles();
                }
                if (selectionModel.getSelected().isEmpty() == false) {
                    if (contextMenu == null) {
                        contextMenu = buildContextMenu();
                    }

                    contextMenu.hide();
                    contextMenu.show(GroupPane.this, t.getScreenX(), t.getScreenY());
                }
                t.consume();
                break;
            }
        }
    }
}