org.sleuthkit.autopsy.timeline.ui.listvew.ListTimeline.java Source code

Java tutorial

Introduction

Here is the source code for org.sleuthkit.autopsy.timeline.ui.listvew.ListTimeline.java

Source

/*
 * Autopsy Forensic Browser
 *
 * Copyright 2011-2016 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.timeline.ui.listvew;

import com.google.common.collect.Iterables;
import java.time.Instant;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoField;
import java.time.temporal.TemporalUnit;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.SortedSet;
import java.util.TreeSet;
import java.util.concurrent.ConcurrentSkipListSet;
import java.util.function.Function;
import java.util.logging.Level;
import java.util.stream.Collectors;
import javafx.application.Platform;
import javafx.beans.Observable;
import javafx.beans.binding.Bindings;
import javafx.beans.binding.IntegerBinding;
import javafx.beans.binding.StringBinding;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.fxml.FXML;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.ComboBox;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.MenuItem;
import javafx.scene.control.OverrunStyle;
import javafx.scene.control.SelectionMode;
import javafx.scene.control.SeparatorMenuItem;
import javafx.scene.control.TableCell;
import javafx.scene.control.TableColumn;
import javafx.scene.control.TableRow;
import javafx.scene.control.TableView;
import javafx.scene.control.Tooltip;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.VBox;
import javafx.util.Callback;
import javax.swing.Action;
import javax.swing.JMenuItem;
import org.apache.commons.lang3.StringUtils;
import org.controlsfx.control.Notifications;
import org.controlsfx.control.action.ActionUtils;
import org.openide.awt.Actions;
import org.openide.util.NbBundle;
import org.openide.util.actions.Presenter;
import org.sleuthkit.autopsy.casemodule.services.TagsManager;
import org.sleuthkit.autopsy.coreutils.Logger;
import org.sleuthkit.autopsy.coreutils.ThreadConfined;
import org.sleuthkit.autopsy.timeline.FXMLConstructor;
import org.sleuthkit.autopsy.timeline.TimeLineController;
import org.sleuthkit.autopsy.timeline.datamodel.CombinedEvent;
import org.sleuthkit.autopsy.timeline.datamodel.SingleEvent;
import org.sleuthkit.autopsy.timeline.datamodel.eventtype.BaseTypes;
import org.sleuthkit.autopsy.timeline.datamodel.eventtype.EventType;
import org.sleuthkit.autopsy.timeline.datamodel.eventtype.FileSystemTypes;
import org.sleuthkit.autopsy.timeline.explorernodes.EventNode;
import org.sleuthkit.autopsy.timeline.zooming.DescriptionLoD;
import org.sleuthkit.datamodel.AbstractFile;
import org.sleuthkit.datamodel.BlackboardArtifact;
import org.sleuthkit.datamodel.SleuthkitCase;
import org.sleuthkit.datamodel.TskCoreException;

/**
 * The inner component that makes up the List view. Manages the TableView.
 */
class ListTimeline extends BorderPane {

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

    private static final Image HASH_HIT = new Image("/org/sleuthkit/autopsy/images/hashset_hits.png"); //NON-NLS 
    private static final Image TAG = new Image("/org/sleuthkit/autopsy/images/green-tag-icon-16.png"); //NON-NLS
    private static final Image FIRST = new Image("/org/sleuthkit/autopsy/timeline/images/resultset_first.png"); //NON-NLS
    private static final Image PREVIOUS = new Image(
            "/org/sleuthkit/autopsy/timeline/images/resultset_previous.png"); //NON-NLS
    private static final Image NEXT = new Image("/org/sleuthkit/autopsy/timeline/images/resultset_next.png"); //NON-NLS
    private static final Image LAST = new Image("/org/sleuthkit/autopsy/timeline/images/resultset_last.png"); //NON-NLS

    /**
     * call-back used to wrap the CombinedEvent in a ObservableValue
     */
    private static final Callback<TableColumn.CellDataFeatures<CombinedEvent, CombinedEvent>, ObservableValue<CombinedEvent>> CELL_VALUE_FACTORY = param -> new SimpleObjectProperty<>(
            param.getValue());
    private static final List<ChronoField> SCROLL_BY_UNITS = Arrays.asList(ChronoField.YEAR,
            ChronoField.MONTH_OF_YEAR, ChronoField.DAY_OF_MONTH, ChronoField.HOUR_OF_DAY,
            ChronoField.MINUTE_OF_HOUR, ChronoField.SECOND_OF_MINUTE);

    @FXML
    private HBox navControls;

    @FXML
    private ComboBox<ChronoField> scrollInrementComboBox;

    @FXML
    private Button firstButton;

    @FXML
    private Button previousButton;

    @FXML
    private Button nextButton;

    @FXML
    private Button lastButton;

    @FXML
    private Label eventCountLabel;
    @FXML
    private TableView<CombinedEvent> table;
    @FXML
    private TableColumn<CombinedEvent, CombinedEvent> idColumn;
    @FXML
    private TableColumn<CombinedEvent, CombinedEvent> dateTimeColumn;
    @FXML
    private TableColumn<CombinedEvent, CombinedEvent> descriptionColumn;
    @FXML
    private TableColumn<CombinedEvent, CombinedEvent> typeColumn;
    @FXML
    private TableColumn<CombinedEvent, CombinedEvent> knownColumn;
    @FXML
    private TableColumn<CombinedEvent, CombinedEvent> taggedColumn;
    @FXML
    private TableColumn<CombinedEvent, CombinedEvent> hashHitColumn;

    /**
     * Observable list used to track selected events.
     */
    private final ObservableList<Long> selectedEventIDs = FXCollections.observableArrayList();

    private final ConcurrentSkipListSet<CombinedEvent> visibleEvents;

    private final TimeLineController controller;
    private final SleuthkitCase sleuthkitCase;
    private final TagsManager tagsManager;

    /**
     * Constructor
     *
     * @param controller The controller for this timeline
     */
    ListTimeline(TimeLineController controller) {

        this.controller = controller;
        sleuthkitCase = controller.getAutopsyCase().getSleuthkitCase();
        tagsManager = controller.getAutopsyCase().getServices().getTagsManager();
        FXMLConstructor.construct(this, ListTimeline.class, "ListTimeline.fxml"); //NON-NLS
        this.visibleEvents = new ConcurrentSkipListSet<>(Comparator.comparing(table.getItems()::indexOf));
    }

    @FXML
    @NbBundle.Messages({ "# {0} - the number of events", "ListTimeline.eventCountLabel.text={0} events" })
    void initialize() {
        assert eventCountLabel != null : "fx:id=\"eventCountLabel\" was not injected: check your FXML file 'ListViewPane.fxml'."; //NON-NLS
        assert table != null : "fx:id=\"table\" was not injected: check your FXML file 'ListViewPane.fxml'."; //NON-NLS
        assert idColumn != null : "fx:id=\"idColumn\" was not injected: check your FXML file 'ListViewPane.fxml'."; //NON-NLS
        assert dateTimeColumn != null : "fx:id=\"dateTimeColumn\" was not injected: check your FXML file 'ListViewPane.fxml'."; //NON-NLS
        assert descriptionColumn != null : "fx:id=\"descriptionColumn\" was not injected: check your FXML file 'ListViewPane.fxml'."; //NON-NLS
        assert typeColumn != null : "fx:id=\"typeColumn\" was not injected: check your FXML file 'ListViewPane.fxml'."; //NON-NLS
        assert knownColumn != null : "fx:id=\"knownColumn\" was not injected: check your FXML file 'ListViewPane.fxml'."; //NON-NLS

        scrollInrementComboBox.setButtonCell(new ChronoFieldListCell());
        scrollInrementComboBox.setCellFactory(comboBox -> new ChronoFieldListCell());
        scrollInrementComboBox.getItems().setAll(SCROLL_BY_UNITS);
        scrollInrementComboBox.getSelectionModel().select(ChronoField.YEAR);

        ActionUtils.configureButton(new ScrollToFirst(), firstButton);
        ActionUtils.configureButton(new ScrollToPrevious(), previousButton);
        ActionUtils.configureButton(new ScrollToNext(), nextButton);
        ActionUtils.configureButton(new ScrollToLast(), lastButton);

        //override default row with one that provides context menus
        table.setRowFactory(tableView -> new EventRow());

        //remove idColumn (can be restored for debugging).  
        table.getColumns().remove(idColumn);

        //// set up cell and cell-value factories for columns
        dateTimeColumn.setCellValueFactory(CELL_VALUE_FACTORY);
        dateTimeColumn.setCellFactory(col -> new TextEventTableCell(
                singleEvent -> TimeLineController.getZonedFormatter().print(singleEvent.getStartMillis())));

        descriptionColumn.setCellValueFactory(CELL_VALUE_FACTORY);
        descriptionColumn.setCellFactory(
                col -> new TextEventTableCell(singleEvent -> singleEvent.getDescription(DescriptionLoD.FULL)));

        typeColumn.setCellValueFactory(CELL_VALUE_FACTORY);
        typeColumn.setCellFactory(col -> new EventTypeCell());

        knownColumn.setCellValueFactory(CELL_VALUE_FACTORY);
        knownColumn.setCellFactory(col -> new TextEventTableCell(singleEvent -> singleEvent.getKnown().getName()));

        taggedColumn.setCellValueFactory(CELL_VALUE_FACTORY);
        taggedColumn.setCellFactory(col -> new TaggedCell());

        hashHitColumn.setCellValueFactory(CELL_VALUE_FACTORY);
        hashHitColumn.setCellFactory(col -> new HashHitCell());

        //bind event count label to number of items in the table
        eventCountLabel.textProperty().bind(new StringBinding() {
            {
                bind(table.getItems());
            }

            @Override
            protected String computeValue() {
                return Bundle.ListTimeline_eventCountLabel_text(table.getItems().size());
            }
        });

        table.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
        table.getSelectionModel().getSelectedItems().addListener((Observable observable) -> {
            //keep the selectedEventsIDs in sync with the table's selection model, via getRepresentitiveEventID(). 
            selectedEventIDs.setAll(table.getSelectionModel().getSelectedItems().stream().filter(Objects::nonNull)
                    .map(CombinedEvent::getRepresentativeEventID).collect(Collectors.toSet()));
        });
    }

    /**
     * Clear all the events out of the table.
     */
    @ThreadConfined(type = ThreadConfined.ThreadType.JFX)
    void clear() {
        table.getItems().clear();
    }

    /**
     * Set the Collection of CombinedEvents to show in the table.
     *
     * @param events The Collection of events to sho in the table.
     */
    @ThreadConfined(type = ThreadConfined.ThreadType.JFX)
    void setCombinedEvents(Collection<CombinedEvent> events) {
        table.getItems().setAll(events);
    }

    /**
     * Get an ObservableList of IDs of events that are selected in this table.
     *
     * @return An ObservableList of IDs of events that are selected in this
     *         table.
     */
    ObservableList<Long> getSelectedEventIDs() {
        return selectedEventIDs;
    }

    /**
     * Get an ObservableList of combined events that are selected in this table.
     *
     * @return An ObservableList of combined events that are selected in this
     *         table.
     */
    ObservableList<CombinedEvent> getSelectedEvents() {
        return table.getSelectionModel().getSelectedItems();
    }

    /**
     * Set the combined events that are selected in this view.
     *
     * @param selectedEvents The events that should be selected.
     */
    void selectEvents(Collection<CombinedEvent> selectedEvents) {
        CombinedEvent firstSelected = selectedEvents.stream()
                .min(Comparator.comparing(CombinedEvent::getStartMillis)).orElse(null);
        table.getSelectionModel().clearSelection();
        table.scrollTo(firstSelected);
        selectedEvents.forEach(table.getSelectionModel()::select);
        table.requestFocus();
    }

    List<Node> getNavControls() {
        return Collections.singletonList(navControls);
    }

    private void scrollToAndFocus(Integer index) {
        table.requestFocus();
        if (visibleEvents.contains(table.getItems().get(index)) == false) {
            table.scrollTo(index);
        }
        table.getFocusModel().focus(index);
    }

    /**
     * TableCell to show the (sub) type of an event.
     */
    private class EventTypeCell extends EventTableCell {

        @NbBundle.Messages({ "ListView.EventTypeCell.modifiedTooltip=File Modified ( M )",
                "ListView.EventTypeCell.accessedTooltip=File Accessed ( A )",
                "ListView.EventTypeCell.createdTooltip=File Created ( B, for Born )",
                "ListView.EventTypeCell.changedTooltip=File Changed ( C )" })
        @Override
        protected void updateItem(CombinedEvent item, boolean empty) {
            super.updateItem(item, empty);

            if (empty || item == null) {
                setText(null);
                setGraphic(null);
                setTooltip(null);
            } else {
                if (item.getEventTypes().stream().allMatch(eventType -> eventType instanceof FileSystemTypes)) {
                    String typeString = ""; //NON-NLS
                    VBox toolTipVbox = new VBox(5);

                    for (FileSystemTypes type : Arrays.asList(FileSystemTypes.FILE_MODIFIED,
                            FileSystemTypes.FILE_ACCESSED, FileSystemTypes.FILE_CHANGED,
                            FileSystemTypes.FILE_CREATED)) {
                        if (item.getEventTypes().contains(type)) {
                            switch (type) {
                            case FILE_MODIFIED:
                                typeString += "M"; //NON-NLS
                                toolTipVbox.getChildren()
                                        .add(new Label(Bundle.ListView_EventTypeCell_modifiedTooltip(),
                                                new ImageView(type.getFXImage())));
                                break;
                            case FILE_ACCESSED:
                                typeString += "A"; //NON-NLS
                                toolTipVbox.getChildren()
                                        .add(new Label(Bundle.ListView_EventTypeCell_accessedTooltip(),
                                                new ImageView(type.getFXImage())));
                                break;
                            case FILE_CREATED:
                                typeString += "B"; //NON-NLS
                                toolTipVbox.getChildren()
                                        .add(new Label(Bundle.ListView_EventTypeCell_createdTooltip(),
                                                new ImageView(type.getFXImage())));
                                break;
                            case FILE_CHANGED:
                                typeString += "C"; //NON-NLS
                                toolTipVbox.getChildren()
                                        .add(new Label(Bundle.ListView_EventTypeCell_changedTooltip(),
                                                new ImageView(type.getFXImage())));
                                break;
                            default:
                                throw new UnsupportedOperationException("Unknown FileSystemType: " + type.name()); //NON-NLS
                            }
                        } else {
                            typeString += "_"; //NON-NLS
                        }
                    }
                    setText(typeString);
                    setGraphic(new ImageView(BaseTypes.FILE_SYSTEM.getFXImage()));
                    Tooltip tooltip = new Tooltip();
                    tooltip.setGraphic(toolTipVbox);
                    setTooltip(tooltip);

                } else {
                    EventType eventType = Iterables.getOnlyElement(item.getEventTypes());
                    setText(eventType.getDisplayName());
                    setGraphic(new ImageView(eventType.getFXImage()));
                    setTooltip(new Tooltip(eventType.getDisplayName()));
                }
                ;
            }
        }
    }

    /**
     * A TableCell that shows information about the tags applied to a event.
     */
    private class TaggedCell extends EventTableCell {

        /**
         * Constructor
         */
        TaggedCell() {
            setAlignment(Pos.CENTER);
        }

        @NbBundle.Messages({
                "ListTimeline.taggedTooltip.error=There was a problem getting the tag names for the selected event.",
                "# {0} - tag names", "ListTimeline.taggedTooltip.text=Tags:\n{0}" })
        @Override
        protected void updateItem(CombinedEvent item, boolean empty) {
            super.updateItem(item, empty);

            if (empty || item == null || (getEvent().isTagged() == false)) {
                setGraphic(null);
                setTooltip(null);
            } else {
                /*
                 * if the cell is not empty and the event is tagged, show the
                 * tagged icon, and show a list of tag names in the tooltip
                 */
                setGraphic(new ImageView(TAG));

                SortedSet<String> tagNames = new TreeSet<>();
                try {
                    //get file tags
                    AbstractFile abstractFileById = sleuthkitCase.getAbstractFileById(getEvent().getFileID());
                    tagsManager.getContentTagsByContent(abstractFileById).stream()
                            .map(tag -> tag.getName().getDisplayName()).forEach(tagNames::add);

                } catch (TskCoreException ex) {
                    LOGGER.log(Level.SEVERE, "Failed to lookup tags for obj id " + getEvent().getFileID(), ex); //NON-NLS
                    Platform.runLater(() -> {
                        Notifications.create().owner(getScene().getWindow())
                                .text(Bundle.ListTimeline_taggedTooltip_error()).showError();
                    });
                }
                getEvent().getArtifactID().ifPresent(artifactID -> {
                    //get artifact tags, if there is an artifact associated with the event.
                    try {
                        BlackboardArtifact artifact = sleuthkitCase.getBlackboardArtifact(artifactID);
                        tagsManager.getBlackboardArtifactTagsByArtifact(artifact).stream()
                                .map(tag -> tag.getName().getDisplayName()).forEach(tagNames::add);
                    } catch (TskCoreException ex) {
                        LOGGER.log(Level.SEVERE, "Failed to lookup tags for artifact id " + artifactID, ex); //NON-NLS
                        Platform.runLater(() -> {
                            Notifications.create().owner(getScene().getWindow())
                                    .text(Bundle.ListTimeline_taggedTooltip_error()).showError();
                        });
                    }
                });
                Tooltip tooltip = new Tooltip(Bundle.ListTimeline_taggedTooltip_text(String.join("\n", tagNames))); //NON-NLS
                tooltip.setGraphic(new ImageView(TAG));
                setTooltip(tooltip);
            }
        }
    }

    /**
     * TableCell to show the hash hits if any associated with the file backing
     * an event.
     */
    private class HashHitCell extends EventTableCell {

        /**
         * Constructor
         */
        HashHitCell() {
            setAlignment(Pos.CENTER);
        }

        @NbBundle.Messages({
                "ListTimeline.hashHitTooltip.error=There was a problem getting the hash set names for the selected event.",
                "# {0} - hash set names", "ListTimeline.hashHitTooltip.text=Hash Sets:\n{0}" })
        @Override
        protected void updateItem(CombinedEvent item, boolean empty) {
            super.updateItem(item, empty);

            if (empty || item == null || (getEvent().isHashHit() == false)) {
                setGraphic(null);
                setTooltip(null);
            } else {
                /*
                 * if the cell is not empty and the event's file is a hash hit,
                 * show the hash hit icon, and show a list of hash set names in
                 * the tooltip
                 */
                setGraphic(new ImageView(HASH_HIT));
                try {
                    Set<String> hashSetNames = new TreeSet<>(
                            sleuthkitCase.getAbstractFileById(getEvent().getFileID()).getHashSetNames());
                    Tooltip tooltip = new Tooltip(
                            Bundle.ListTimeline_hashHitTooltip_text(String.join("\n", hashSetNames))); //NON-NLS
                    tooltip.setGraphic(new ImageView(HASH_HIT));
                    setTooltip(tooltip);
                } catch (TskCoreException ex) {
                    LOGGER.log(Level.SEVERE, "Failed to lookup hash set names for obj id " + getEvent().getFileID(),
                            ex); //NON-NLS
                    Platform.runLater(() -> {
                        Notifications.create().owner(getScene().getWindow())
                                .text(Bundle.ListTimeline_hashHitTooltip_error()).showError();
                    });
                }
            }
        }
    }

    /**
     * TableCell to show text derived from a SingleEvent by the given Function.
     */
    private class TextEventTableCell extends EventTableCell {

        private final Function<SingleEvent, String> textSupplier;

        /**
         * Constructor
         *
         * @param textSupplier Function that takes a SingleEvent and produces a
         *                     String to show in this TableCell.
         */
        TextEventTableCell(Function<SingleEvent, String> textSupplier) {
            this.textSupplier = textSupplier;
            setTextOverrun(OverrunStyle.CENTER_ELLIPSIS);
            setEllipsisString(" ... "); //NON-NLS
        }

        @Override
        protected void updateItem(CombinedEvent item, boolean empty) {
            super.updateItem(item, empty);
            if (empty || item == null) {
                setText(null);
            } else {
                setText(textSupplier.apply(getEvent()));
            }
        }
    }

    /**
     * Base class for TableCells that represent a MergedEvent by way of a
     * representative SingleEvent.
     */
    private abstract class EventTableCell extends TableCell<CombinedEvent, CombinedEvent> {

        private SingleEvent event;

        /**
         * Get the representative SingleEvent for this cell.
         *
         * @return The representative SingleEvent for this cell.
         */
        SingleEvent getEvent() {
            return event;
        }

        @Override
        protected void updateItem(CombinedEvent item, boolean empty) {
            super.updateItem(item, empty);

            if (empty || item == null) {
                event = null;
            } else {
                //stash the event in the cell for derived classed to use.
                event = controller.getEventsModel().getEventById(item.getRepresentativeEventID());
            }
        }
    }

    /**
     * TableRow that adds a right-click context menu.
     */
    private class EventRow extends TableRow<CombinedEvent> {

        private SingleEvent event;

        /**
         * Get the representative SingleEvent for this row .
         *
         * @return The representative SingleEvent for this row .
         */
        SingleEvent getEvent() {
            return event;
        }

        @NbBundle.Messages({ "ListChart.errorMsg=There was a problem getting the content for the selected event." })
        @Override
        protected void updateItem(CombinedEvent item, boolean empty) {
            CombinedEvent oldItem = getItem();
            if (oldItem != null) {
                visibleEvents.remove(oldItem);
            }
            super.updateItem(item, empty);

            if (empty || item == null) {
                event = null;
            } else {
                visibleEvents.add(item);
                event = controller.getEventsModel().getEventById(item.getRepresentativeEventID());

                setOnContextMenuRequested(contextMenuEvent -> {
                    //make a new context menu on each request in order to include uptodate tag names and hash sets
                    try {
                        EventNode node = EventNode.createEventNode(item.getRepresentativeEventID(),
                                controller.getEventsModel());
                        List<MenuItem> menuItems = new ArrayList<>();

                        //for each actions avaialable on node, make a menu item.
                        for (Action action : node.getActions(false)) {
                            if (action == null) {
                                // swing/netbeans uses null action to represent separator in menu
                                menuItems.add(new SeparatorMenuItem());
                            } else {
                                String actionName = Objects.toString(action.getValue(Action.NAME));
                                //for now, suppress properties and tools actions, by ignoring them
                                if (Arrays.asList("&Properties", "Tools").contains(actionName) == false) { //NON-NLS
                                    if (action instanceof Presenter.Popup) {
                                        /*
                                         * If the action is really the root of a
                                         * set of actions (eg, tagging). Make a
                                         * menu that parallels the action's
                                         * menu.
                                         */
                                        JMenuItem submenu = ((Presenter.Popup) action).getPopupPresenter();
                                        menuItems.add(SwingFXMenuUtils.createFXMenu(submenu));
                                    } else {
                                        menuItems.add(
                                                SwingFXMenuUtils.createFXMenu(new Actions.MenuItem(action, false)));
                                    }
                                }
                            }
                        }
                        ;

                        //show new context menu.
                        new ContextMenu(menuItems.toArray(new MenuItem[menuItems.size()])).show(this,
                                contextMenuEvent.getScreenX(), contextMenuEvent.getScreenY());
                    } catch (IllegalStateException ex) {
                        //Since the case is closed, the user probably doesn't care about this, just log it as a precaution.
                        LOGGER.log(Level.SEVERE,
                                "There was no case open to lookup the Sleuthkit object backing a SingleEvent.", ex); //NON-NLS
                    } catch (TskCoreException ex) {
                        LOGGER.log(Level.SEVERE, "Failed to lookup Sleuthkit object backing a SingleEvent.", ex); //NON-NLS
                        Platform.runLater(() -> {
                            Notifications.create().owner(getScene().getWindow()).text(Bundle.ListChart_errorMsg())
                                    .showError();
                        });
                    }
                });

            }
        }
    }

    private class ChronoFieldListCell extends ListCell<ChronoField> {

        @Override
        protected void updateItem(ChronoField item, boolean empty) {
            super.updateItem(item, empty);

            if (empty || item == null) {
                setText(null);
            } else {
                String displayName = item.getDisplayName(Locale.getDefault());
                setText(String.join(" ", StringUtils.splitByCharacterTypeCamelCase(displayName)));
            }
        }
    }

    private class ScrollToFirst extends org.controlsfx.control.action.Action {

        ScrollToFirst() {
            super("", actionEvent -> scrollToAndFocus(0));
            setGraphic(new ImageView(FIRST));
            disabledProperty().bind(table.getFocusModel().focusedIndexProperty().lessThan(1));
        }
    }

    private class ScrollToLast extends org.controlsfx.control.action.Action {

        ScrollToLast() {
            super("", actionEvent -> scrollToAndFocus(table.getItems().size() - 1));
            setGraphic(new ImageView(LAST));
            IntegerBinding size = Bindings.size(table.getItems());
            disabledProperty().bind(size.isEqualTo(0)
                    .or(table.getFocusModel().focusedIndexProperty().greaterThanOrEqualTo(size.subtract(1))));
        }
    }

    private class ScrollToNext extends org.controlsfx.control.action.Action {

        ScrollToNext() {
            super("", actionEvent -> {

                ChronoField selectedChronoField = scrollInrementComboBox.getSelectionModel().getSelectedItem();
                ZoneId timeZoneID = TimeLineController.getTimeZoneID();
                TemporalUnit selectedUnit = selectedChronoField.getBaseUnit();

                int focusedIndex = table.getFocusModel().getFocusedIndex();
                CombinedEvent focusedItem = table.getFocusModel().getFocusedItem();
                if (-1 == focusedIndex || null == focusedItem) {
                    focusedItem = visibleEvents.first();
                    focusedIndex = table.getItems().indexOf(focusedItem);
                }

                ZonedDateTime focusedDateTime = Instant.ofEpochMilli(focusedItem.getStartMillis())
                        .atZone(timeZoneID);
                ZonedDateTime nextDateTime = focusedDateTime.plus(1, selectedUnit);//
                for (ChronoField field : SCROLL_BY_UNITS) {
                    if (field.getBaseUnit().getDuration().compareTo(selectedUnit.getDuration()) < 0) {
                        nextDateTime = nextDateTime.with(field, field.rangeRefinedBy(nextDateTime).getMinimum());//
                    }
                }
                long nextMillis = nextDateTime.toInstant().toEpochMilli();

                int nextIndex = table.getItems().size() - 1;
                for (int i = focusedIndex; i < table.getItems().size(); i++) {
                    if (table.getItems().get(i).getStartMillis() >= nextMillis) {
                        nextIndex = i;
                        break;
                    }
                }
                scrollToAndFocus(nextIndex);
            });
            setGraphic(new ImageView(NEXT));
            IntegerBinding size = Bindings.size(table.getItems());
            disabledProperty().bind(size.isEqualTo(0)
                    .or(table.getFocusModel().focusedIndexProperty().greaterThanOrEqualTo(size.subtract(1))));
        }

    }

    private class ScrollToPrevious extends org.controlsfx.control.action.Action {

        ScrollToPrevious() {
            super("", actionEvent -> {
                ZoneId timeZoneID = TimeLineController.getTimeZoneID();
                ChronoField selectedChronoField = scrollInrementComboBox.getSelectionModel().getSelectedItem();
                TemporalUnit selectedUnit = selectedChronoField.getBaseUnit();

                int focusedIndex = table.getFocusModel().getFocusedIndex();
                CombinedEvent focusedItem = table.getFocusModel().getFocusedItem();
                if (-1 == focusedIndex || null == focusedItem) {
                    focusedItem = visibleEvents.last();
                    focusedIndex = table.getItems().indexOf(focusedItem);
                }

                ZonedDateTime focusedDateTime = Instant.ofEpochMilli(focusedItem.getStartMillis())
                        .atZone(timeZoneID);
                ZonedDateTime previousDateTime = focusedDateTime.minus(1, selectedUnit);//

                for (ChronoField field : SCROLL_BY_UNITS) {
                    if (field.getBaseUnit().getDuration().compareTo(selectedUnit.getDuration()) < 0) {
                        previousDateTime = previousDateTime.with(field,
                                field.rangeRefinedBy(previousDateTime).getMaximum());//
                    }
                }
                long previousMillis = previousDateTime.toInstant().toEpochMilli();

                int previousIndex = 0;
                for (int i = focusedIndex; i > 0; i--) {
                    if (table.getItems().get(i).getStartMillis() <= previousMillis) {
                        previousIndex = i;
                        break;
                    }
                }

                scrollToAndFocus(previousIndex);
            });
            setGraphic(new ImageView(PREVIOUS));
            disabledProperty().bind(table.getFocusModel().focusedIndexProperty().lessThan(1));
        }
    }

}