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

Java tutorial

Introduction

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

Source

/*
 * Autopsy Forensic Browser
 *
 * Copyright 2013-15 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;

import com.google.common.collect.ImmutableMap;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
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.Set;
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.property.ReadOnlyObjectProperty;
import javafx.beans.property.ReadOnlyObjectWrapper;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ObservableValue;
import javafx.collections.ObservableList;
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.image.ImageView;
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.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
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.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.TagUtils;
import org.sleuthkit.autopsy.imagegallery.actions.AddDrawableTagAction;
import org.sleuthkit.autopsy.imagegallery.actions.Back;
import org.sleuthkit.autopsy.imagegallery.actions.CategorizeAction;
import org.sleuthkit.autopsy.imagegallery.actions.Forward;
import org.sleuthkit.autopsy.imagegallery.actions.NextUnseenGroup;
import org.sleuthkit.autopsy.imagegallery.actions.SwingMenuItemAdapter;
import org.sleuthkit.autopsy.imagegallery.datamodel.Category;
import org.sleuthkit.autopsy.imagegallery.datamodel.DrawableAttribute;
import org.sleuthkit.autopsy.imagegallery.grouping.DrawableGroup;
import org.sleuthkit.autopsy.imagegallery.grouping.GroupViewMode;
import org.sleuthkit.autopsy.imagegallery.grouping.GroupViewState;
import org.sleuthkit.datamodel.TagName;
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. 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 implements GroupView {

    private static final Logger LOGGER = Logger.getLogger(GroupPane.class.getName());

    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 static final FileIDSelectionModel globalSelectionModel = FileIDSelectionModel.getInstance();
    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 SplitMenuButton grpCatSplitMenu;

    @FXML
    private SplitMenuButton grpTagSplitMenu;

    @FXML
    private ToolBar headerToolBar;

    @FXML
    private SegmentedButton segButton;

    private SlideShowView slideShowPane;

    @FXML
    private ToggleButton slideShowToggle;

    @FXML
    private Region spacer;

    @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;

    private final KeyboardHandler tileKeyboardNavigationHandler = new KeyboardHandler();

    private final NextUnseenGroup nextGroupAction;

    private final ImageGalleryController controller;

    private ContextMenu contextMenu;

    private Integer selectionAnchorIndex;

    /**
     * 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 = getGrouping().fileIds();
        Platform.runLater(() -> {
            slideShowToggle.setDisable(fileIds.isEmpty());
            gridView.getItems().setAll(fileIds);
            groupLabel.setText(header);
        });
    };

    public GroupPane(ImageGalleryController controller) {
        this.controller = controller;
        nextGroupAction = new NextUnseenGroup(controller);
        backAction = new Back(controller);
        forwardAction = new Forward(controller);
        FXMLConstructor.construct(this, "GroupPane.fxml");
    }

    @ThreadConfined(type = ThreadType.JFX)
    public void activateSlideShowViewer(Long slideShowFileID) {
        groupViewMode.set(GroupViewMode.SLIDE_SHOW);

        //make a new slideShowPane if necessary
        if (slideShowPane == null) {
            slideShowPane = new SlideShowView(this);
        }

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

    }

    public void activateTileViewer() {

        groupViewMode.set(GroupViewMode.TILE);
        setCenter(gridView);
        gridView.requestFocus();
        if (slideShowPane != null) {
            slideShowPane.disposeContent();
        }
        slideShowPane = null;
        this.scrollToFileID(globalSelectionModel.lastSelectedProperty().get());
    }

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

    private MenuItem createGrpCatMenuItem(final Category cat) {
        final MenuItem menuItem = new MenuItem(cat.getDisplayName(),
                new ImageView(DrawableAttribute.CATEGORY.getIcon()));
        menuItem.setOnAction(new EventHandler<ActionEvent>() {
            @Override
            public void handle(ActionEvent t) {
                Set<Long> fileIdSet = new HashSet<>(getGrouping().fileIds());
                new CategorizeAction().addTagsToFiles(cat.getTagName(), "", fileIdSet);

                grpCatSplitMenu.setText(cat.getDisplayName());
                grpCatSplitMenu.setOnAction(this);
            }
        });
        return menuItem;
    }

    private MenuItem createGrpTagMenuItem(final TagName tn) {
        final MenuItem menuItem = new MenuItem(tn.getDisplayName(),
                new ImageView(DrawableAttribute.TAGS.getIcon()));
        menuItem.setOnAction(new EventHandler<ActionEvent>() {
            @Override
            public void handle(ActionEvent t) {
                Set<Long> fileIdSet = new HashSet<>(getGrouping().fileIds());
                AddDrawableTagAction.getInstance().addTagsToFiles(tn, "", fileIdSet);

                grpTagSplitMenu.setText(tn.getDisplayName());
                grpTagSplitMenu.setOnAction(this);
            }
        });
        return menuItem;
    }

    private void selectAllFiles() {
        globalSelectionModel.clearAndSelectAll(getGrouping().fileIds());
    }

    /** create the string to display in the group header */
    protected String getHeaderString() {
        return isNull(getGrouping()) ? ""
                : StringUtils.defaultIfBlank(getGrouping().getGroupByValueDislpayName(),
                        DrawableGroup.getBlankGroupName()) + " -- " + getGrouping().getHashSetHitsCount()
                        + " hash set hits / " + getGrouping().getSize() + " files";
    }

    ContextMenu getContextMenu() {
        return contextMenu;
    }

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

    /**
     * called automatically during constructor by FXMLConstructor.
     *
     * checks that FXML loading went ok and performs additional setup
     */
    @FXML
    void initialize() {
        assert gridView != null : "fx:id=\"tilePane\" was not injected: check your FXML file 'GroupPane.fxml'.";
        assert grpCatSplitMenu != null : "fx:id=\"grpCatSplitMenu\" was not injected: check your FXML file 'GroupHeader.fxml'.";
        assert grpTagSplitMenu != 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'.";

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

        //configure gridView cell properties
        gridView.cellHeightProperty().bind(Toolbar.getDefault().sizeSliderValue().add(75));
        gridView.cellWidthProperty().bind(Toolbar.getDefault().sizeSliderValue().add(75));
        gridView.setCellFactory((GridView<Long> param) -> new DrawableCell());

        //configure toolbar properties
        HBox.setHgrow(spacer, Priority.ALWAYS);
        spacer.setMinWidth(Region.USE_PREF_SIZE);

        try {
            grpTagSplitMenu.setText(TagUtils.getFollowUpTagName().getDisplayName());
            grpTagSplitMenu.setOnAction(createGrpTagMenuItem(TagUtils.getFollowUpTagName()).getOnAction());
        } catch (TskCoreException tskCoreException) {
            LOGGER.log(Level.WARNING, "failed to load FollowUpTagName", tskCoreException);
        }
        grpTagSplitMenu.setGraphic(new ImageView(DrawableAttribute.TAGS.getIcon()));
        grpTagSplitMenu.showingProperty()
                .addListener((ObservableValue<? extends Boolean> ov, Boolean t, Boolean t1) -> {
                    if (t1) {
                        ArrayList<MenuItem> selTagMenues = new ArrayList<>();
                        for (final TagName tn : TagUtils.getNonCategoryTagNames()) {
                            MenuItem menuItem = TagUtils.createSelTagMenuItem(tn, grpTagSplitMenu);
                            selTagMenues.add(menuItem);
                        }
                        grpTagSplitMenu.getItems().setAll(selTagMenues);
                    }
                });

        ArrayList<MenuItem> grpCategoryMenues = new ArrayList<>();
        for (final Category cat : Category.values()) {
            MenuItem menuItem = createGrpCatMenuItem(cat);
            grpCategoryMenues.add(menuItem);
        }
        grpCatSplitMenu.setText(Category.FIVE.getDisplayName());
        grpCatSplitMenu.setGraphic(new ImageView(DrawableAttribute.CATEGORY.getIcon()));
        grpCatSplitMenu.getItems().setAll(grpCategoryMenues);
        grpCatSplitMenu.setOnAction(createGrpCatMenuItem(Category.FIVE).getOnAction());

        Runnable syncMode = () -> {
            switch (groupViewMode.get()) {
            case SLIDE_SHOW:
                slideShowToggle.setSelected(true);
                break;
            case TILE:
                tileToggle.setSelected(true);
                break;
            }
        };
        syncMode.run();
        //make togle states match view state
        groupViewMode.addListener((o) -> {
            syncMode.run();
        });

        slideShowToggle.toggleGroupProperty().addListener((o) -> {
            slideShowToggle.getToggleGroup().selectedToggleProperty()
                    .addListener((observable, oldToggle, newToggle) -> {
                        if (newToggle == null) {
                            oldToggle.setSelected(true);
                        }
                    });
        });

        //listen to toggles and update view state
        slideShowToggle.setOnAction((ActionEvent t) -> {
            activateSlideShowViewer(globalSelectionModel.lastSelectedProperty().get());
        });

        tileToggle.setOnAction((ActionEvent t) -> {
            activateTileViewer();
        });

        controller.viewState().addListener((ObservableValue<? extends GroupViewState> observable,
                GroupViewState oldValue, GroupViewState newValue) -> {
            setViewState(newValue);
        });

        addEventFilter(KeyEvent.KEY_PRESSED, tileKeyboardNavigationHandler);
        gridView.addEventHandler(MouseEvent.MOUSE_CLICKED, new EventHandler<MouseEvent>() {

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

                menuItems.add(CategorizeAction.getPopupMenu());

                menuItems.add(AddDrawableTagAction.getInstance().getPopupMenu());

                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("Extract File(s)");
                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) {
                        globalSelectionModel.clearSelection();
                        if (contextMenu != null) {
                            contextMenu.hide();
                        }
                    }
                    t.consume();
                    break;
                case SECONDARY:
                    if (t.getClickCount() == 1) {
                        selectAllFiles();
                    }
                    if (globalSelectionModel.getSelected().isEmpty() == false) {
                        if (contextMenu == null) {
                            contextMenu = buildContextMenu();
                        }

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

        ActionUtils.configureButton(nextGroupAction, nextButton);
        final EventHandler<ActionEvent> onAction = nextButton.getOnAction();
        nextButton.setOnAction((ActionEvent event) -> {
            flashAnimation.stop();
            nextButton.setEffect(null);
            onAction.handle(event);
        });

        ActionUtils.configureButton(forwardAction, forwardButton);
        ActionUtils.configureButton(backAction, backButton);

        nextGroupAction.disabledProperty().addListener(
                (ObservableValue<? extends Boolean> observable, Boolean oldValue, Boolean newValue) -> {
                    nextButton.setEffect(newValue ? null : DROP_SHADOW);
                    if (newValue == false) {
                        flashAnimation.play();
                    } else {
                        flashAnimation.stop();
                    }
                });

        //listen to tile selection and make sure it is visible in scroll area
        //TODO: make sure we are testing complete visability not just bounds intersection
        globalSelectionModel.lastSelectedProperty().addListener((observable, oldFileID, newFileId) -> {
            if (groupViewMode.get() == GroupViewMode.SLIDE_SHOW) {
                slideShowPane.setFile(newFileId);
            } else {

                scrollToFileID(newFileId);
            }
        });

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

    @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 (nonNull(getGrouping())) {
            getGrouping().fileIds().removeListener(filesSyncListener);
        }

        if (isNull(viewState) || isNull(viewState.getGroup())) {
            this.grouping.set(null);

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

        } else {
            if (this.grouping.get() != viewState.getGroup()) {
                this.grouping.set(viewState.getGroup());

                this.getGrouping().fileIds().addListener(filesSyncListener);

                final String header = getHeaderString();

                gridView.getItems().setAll(getGrouping().fileIds());
                Platform.runLater(() -> {
                    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"));
    }

    void makeSelection(Boolean shiftDown, Long newFileID) {

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

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

    private class DrawableCell extends GridCell<Long> {

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

        public 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() {
            //            updateItem(null, true);
            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().fileIds()
                                .indexOf(globalSelectionModel.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(globalSelectionModel.lastSelectedProperty().get());
                    } else {
                        activateTileViewer();
                    }
                    t.consume();
                    break;
                }

                if (groupViewMode.get() == GroupViewMode.TILE && categoryKeyCodes.contains(t.getCode())
                        && t.isAltDown()) {
                    selectAllFiles();
                    t.consume();
                }
                if (globalSelectionModel.getSelected().isEmpty() == false) {
                    switch (t.getCode()) {
                    case NUMPAD0:
                    case DIGIT0:
                        new CategorizeAction().addTag(Category.ZERO.getTagName(), "");
                        break;
                    case NUMPAD1:
                    case DIGIT1:
                        new CategorizeAction().addTag(Category.ONE.getTagName(), "");
                        break;
                    case NUMPAD2:
                    case DIGIT2:
                        new CategorizeAction().addTag(Category.TWO.getTagName(), "");
                        break;
                    case NUMPAD3:
                    case DIGIT3:
                        new CategorizeAction().addTag(Category.THREE.getTagName(), "");
                        break;
                    case NUMPAD4:
                    case DIGIT4:
                        new CategorizeAction().addTag(Category.FOUR.getTagName(), "");
                        break;
                    case NUMPAD5:
                    case DIGIT5:
                        new CategorizeAction().addTag(Category.FIVE.getTagName(), "");
                        break;
                    }
                }
            }

        }

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

            int lastSelectedIndex = lastSelectFileId != null ? grouping.get().fileIds().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().fileIds().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().fileIds().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().fileIds().get(size - 1));
            } else {
                //don't select past end of group
            }
        }
    }

}