/*
* 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;
}
}
}
}