org.sleuthkit.autopsy.timeline.ui.ViewFrame.java Source code

Java tutorial

Introduction

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

Source

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

import com.google.common.collect.ImmutableList;
import com.google.common.eventbus.Subscribe;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.function.BiFunction;
import java.util.function.Supplier;
import javafx.application.Platform;
import javafx.beans.InvalidationListener;
import javafx.beans.Observable;
import javafx.collections.FXCollections;
import javafx.collections.ObservableList;
import javafx.fxml.FXML;
import javafx.geometry.Insets;
import javafx.scene.Node;
import javafx.scene.control.Button;
import javafx.scene.control.Label;
import javafx.scene.control.MenuButton;
import javafx.scene.control.TitledPane;
import javafx.scene.control.ToggleButton;
import javafx.scene.control.ToolBar;
import javafx.scene.control.Tooltip;
import javafx.scene.effect.Lighting;
import javafx.scene.image.Image;
import javafx.scene.image.ImageView;
import javafx.scene.input.MouseEvent;
import javafx.scene.layout.Background;
import javafx.scene.layout.BackgroundFill;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.CornerRadii;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.Region;
import static javafx.scene.layout.Region.USE_PREF_SIZE;
import javafx.scene.layout.StackPane;
import javafx.scene.paint.Color;
import javafx.util.Callback;
import javax.annotation.Nonnull;
import javax.annotation.concurrent.GuardedBy;
import jfxtras.scene.control.LocalDateTimePicker;
import jfxtras.scene.control.LocalDateTimeTextField;
import jfxtras.scene.control.ToggleGroupValue;
import org.controlsfx.control.NotificationPane;
import org.controlsfx.control.RangeSlider;
import org.controlsfx.control.SegmentedButton;
import org.controlsfx.control.action.Action;
import org.controlsfx.control.action.ActionUtils;
import org.joda.time.DateTime;
import org.joda.time.Interval;
import org.openide.util.NbBundle;
import org.sleuthkit.autopsy.casemodule.events.DataSourceAddedEvent;
import org.sleuthkit.autopsy.coreutils.LoggedTask;
import org.sleuthkit.autopsy.coreutils.Logger;
import org.sleuthkit.autopsy.coreutils.ThreadConfined;
import org.sleuthkit.autopsy.ingest.events.DataSourceAnalysisCompletedEvent;
import org.sleuthkit.autopsy.timeline.FXMLConstructor;
import org.sleuthkit.autopsy.timeline.TimeLineController;
import org.sleuthkit.autopsy.timeline.ViewMode;
import org.sleuthkit.autopsy.timeline.actions.Back;
import org.sleuthkit.autopsy.timeline.actions.ResetFilters;
import org.sleuthkit.autopsy.timeline.actions.SaveSnapshotAsReport;
import org.sleuthkit.autopsy.timeline.actions.UpdateDB;
import org.sleuthkit.autopsy.timeline.actions.ZoomIn;
import org.sleuthkit.autopsy.timeline.actions.ZoomOut;
import org.sleuthkit.autopsy.timeline.actions.ZoomToEvents;
import org.sleuthkit.autopsy.timeline.datamodel.FilteredEventsModel;
import org.sleuthkit.autopsy.timeline.events.DBUpdatedEvent;
import org.sleuthkit.autopsy.timeline.events.RefreshRequestedEvent;
import org.sleuthkit.autopsy.timeline.events.TagsUpdatedEvent;
import static org.sleuthkit.autopsy.timeline.ui.Bundle.*;
import org.sleuthkit.autopsy.timeline.ui.countsview.CountsViewPane;
import org.sleuthkit.autopsy.timeline.ui.detailview.DetailViewPane;
import org.sleuthkit.autopsy.timeline.ui.detailview.tree.EventsTree;
import org.sleuthkit.autopsy.timeline.ui.listvew.ListViewPane;
import org.sleuthkit.autopsy.timeline.utils.RangeDivisionInfo;

/**
 * A container for an AbstractTimelineView. Has a Toolbar on top to hold
 * settings widgets supplied by contained AbstractTimelineView, and the
 * histogram / time selection on bottom. The time selection Toolbar has default
 * controls that can be replaced by ones supplied by the current view.
 *
 * TODO: Refactor common code out of histogram and CountsView? -jm
 */
final public class ViewFrame extends BorderPane {

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

    private static final Image INFORMATION = new Image("org/sleuthkit/autopsy/timeline/images/information.png", 16,
            16, true, true); //NON-NLS
    private static final Image WARNING = new Image("org/sleuthkit/autopsy/timeline/images/warning_triangle.png", 16,
            16, true, true); //NON-NLS
    private static final Image REFRESH = new Image(
            "org/sleuthkit/autopsy/timeline/images/arrow-circle-double-135.png"); //NON-NLS
    private static final Background GRAY_BACKGROUND = new Background(
            new BackgroundFill(Color.GREY, CornerRadii.EMPTY, Insets.EMPTY));

    /**
     * Region that will be stacked in between the no-events "dialog" and the
     * hosted AbstractTimelineView in order to gray out the
     * AbstractTimelineView.
     */
    private final static Region NO_EVENTS_BACKGROUND = new Region() {
        {
            setBackground(GRAY_BACKGROUND);
            setOpacity(.3);
        }
    };

    /**
     * The scene graph Nodes for the current view's settings will be inserted
     * into the toolbar at this index.
     */
    private static final int SETTINGS_TOOLBAR_INSERTION_INDEX = 2;

    /**
     * The scene graph Nodes for the current view's time navigation controls
     * will be inserted into the toolbar at this index.
     */
    private static final int TIME_TOOLBAR_INSERTION_INDEX = 2;

    @GuardedBy("this")
    private LoggedTask<Void> histogramTask;

    private final EventsTree eventsTree;
    private AbstractTimeLineView hostedView;

    /*
     * HBox that contains the histogram bars.
     *
     * //TODO: Abstract this into a seperate class, and/or use a real bar
     * chart? -jm
     */
    @FXML
    private HBox histogramBox;
    /*
     * Stack pane that superimposes rangeslider over histogram
     */
    @FXML
    private StackPane rangeHistogramStack;

    private final RangeSlider rangeSlider = new RangeSlider(0, 1.0, .25, .75);

    /**
     * The lower tool bar that has controls to adjust the viewed timerange.
     */
    @FXML
    private ToolBar timeRangeToolBar;

    /**
     * Parent for the default zoom in/out buttons that can be replaced in some
     * views(eg List View)
     */
    @FXML
    private HBox zoomInOutHBox;

    //// time range selection components
    @FXML
    private MenuButton zoomMenuButton;
    @FXML
    private Button zoomOutButton;
    @FXML
    private Button zoomInButton;
    @FXML
    private LocalDateTimeTextField startPicker;
    @FXML
    private LocalDateTimeTextField endPicker;
    @FXML
    private Label startLabel;
    @FXML
    private Label endLabel;

    //// header toolbar componenets
    @FXML
    private ToolBar toolBar;

    private ToggleGroupValue<ViewMode> viewModeToggleGroup;
    @FXML
    private Label viewModeLabel;
    @FXML
    private SegmentedButton modeSegButton;
    @FXML
    private ToggleButton countsToggle;
    @FXML
    private ToggleButton detailsToggle;
    @FXML
    private ToggleButton listToggle;

    @FXML
    private Button snapShotButton;
    @FXML
    private Button refreshButton;
    @FXML
    private Button updateDBButton;

    /*
     * Default zoom in/out buttons provided by the ViewFrame, some views replace
     * these with other nodes (eg, list view)
     */
    @ThreadConfined(type = ThreadConfined.ThreadType.JFX)
    private ImmutableList<Node> defaultTimeNavigationNodes;

    /*
     * The settings nodes for the current view.
     */
    @ThreadConfined(type = ThreadConfined.ThreadType.JFX)
    private final ObservableList<Node> settingsNodes = FXCollections.observableArrayList();

    /*
     * The time nagivation nodes for the current view.
     */
    @ThreadConfined(type = ThreadConfined.ThreadType.JFX)
    private final ObservableList<Node> timeNavigationNodes = FXCollections.observableArrayList();

    /**
     * Wraps the contained AbstractTimelineView so that we can show
     * notifications over it.
     */
    private final NotificationPane notificationPane = new NotificationPane();

    private final TimeLineController controller;
    private final FilteredEventsModel filteredEvents;

    /**
     * Listen to changes in the range slider selection and forward to the
     * controller. Waits until the user releases thumb to send to controller.
     */
    private final InvalidationListener rangeSliderListener = new InvalidationListener() {
        @Override
        public void invalidated(Observable observable) {
            if (rangeSlider.isHighValueChanging() == false && rangeSlider.isLowValueChanging() == false) {
                Long minTime = RangeDivisionInfo.getRangeDivisionInfo(filteredEvents.getSpanningInterval())
                        .getLowerBound();
                if (false == controller.pushTimeRange(new Interval((long) (rangeSlider.getLowValue() + minTime),
                        (long) (rangeSlider.getHighValue() + minTime + 1000)))) {
                    refreshTimeUI();
                }
            }
        }
    };

    /**
     * hides the notification pane on any event
     */
    private final InvalidationListener zoomListener = any -> handleRefreshRequested(null);

    /**
     * listen to change in end time picker and push to controller
     */
    private final InvalidationListener endListener = new PickerListener(() -> endPicker, Interval::withEndMillis);

    /**
     * listen to change in start time picker and push to controller
     */
    private final InvalidationListener startListener = new PickerListener(() -> startPicker,
            Interval::withStartMillis);

    /**
     * Convert the given LocalDateTime to epoch millis USING THE CURRENT
     * TIMEZONE FROM THE TIMELINECONTROLLER
     *
     * @param localDateTime The LocalDateTime to convert to millis since the
     *                      Unix epoch.
     *
     * @return the given LocalDateTime as epoch millis
     */
    private static long localDateTimeToEpochMilli(LocalDateTime localDateTime) {
        return localDateTime.atZone(TimeLineController.getTimeZoneID()).toInstant().toEpochMilli();
    }

    /**
     * Convert the given "millis from the Unix Epoch" to a LocalDateTime USING
     * THE CURRENT TIMEZONE FROM THE TIMELINECONTROLLER
     *
     * @param millis The milliseconds to convert.
     *
     * @return The given epoch millis as a LocalDateTime
     */
    private static LocalDateTime epochMillisToLocalDateTime(long millis) {
        return LocalDateTime.ofInstant(Instant.ofEpochMilli(millis), TimeLineController.getTimeZoneID());
    }

    /**
     * Constructor
     *
     * @param controller The TimeLineController for this ViewFrame
     * @param eventsTree The EventsTree this ViewFrame hosts.
     */
    public ViewFrame(@Nonnull TimeLineController controller, @Nonnull EventsTree eventsTree) {
        this.controller = controller;
        this.filteredEvents = controller.getEventsModel();
        this.eventsTree = eventsTree;
        FXMLConstructor.construct(this, "ViewFrame.fxml"); //NON-NLS

    }

    @FXML
    @NbBundle.Messages({ "ViewFrame.viewModeLabel.text=View Mode:", "ViewFrame.startLabel.text=Start:",
            "ViewFrame.endLabel.text=End:", "ViewFrame.countsToggle.text=Counts",
            "ViewFrame.detailsToggle.text=Details", "ViewFrame.listToggle.text=List",
            "ViewFrame.zoomMenuButton.text=Zoom in/out to",
            "ViewFrame.tagsAddedOrDeleted=Tags have been created and/or deleted.  The view may not be up to date." })
    void initialize() {
        assert endPicker != null : "fx:id=\"endPicker\" was not injected: check your FXML file 'ViewWrapper.fxml'."; //NON-NLS
        assert histogramBox != null : "fx:id=\"histogramBox\" was not injected: check your FXML file 'ViewWrapper.fxml'."; //NON-NLS
        assert startPicker != null : "fx:id=\"startPicker\" was not injected: check your FXML file 'ViewWrapper.fxml'."; //NON-NLS
        assert rangeHistogramStack != null : "fx:id=\"rangeHistogramStack\" was not injected: check your FXML file 'ViewWrapper.fxml'."; //NON-NLS
        assert countsToggle != null : "fx:id=\"countsToggle\" was not injected: check your FXML file 'VisToggle.fxml'."; //NON-NLS
        assert detailsToggle != null : "fx:id=\"eventsToggle\" was not injected: check your FXML file 'VisToggle.fxml'."; //NON-NLS

        defaultTimeNavigationNodes = ImmutableList.of(zoomInOutHBox, zoomMenuButton);
        timeNavigationNodes.setAll(defaultTimeNavigationNodes);

        //configure notification pane 
        notificationPane.getStyleClass().add(NotificationPane.STYLE_CLASS_DARK);
        setCenter(notificationPane);

        //configure view mode toggle
        viewModeLabel.setText(Bundle.ViewFrame_viewModeLabel_text());
        countsToggle.setText(Bundle.ViewFrame_countsToggle_text());
        detailsToggle.setText(Bundle.ViewFrame_detailsToggle_text());
        listToggle.setText(Bundle.ViewFrame_listToggle_text());
        viewModeToggleGroup = new ToggleGroupValue<>();
        viewModeToggleGroup.add(listToggle, ViewMode.LIST);
        viewModeToggleGroup.add(detailsToggle, ViewMode.DETAIL);
        viewModeToggleGroup.add(countsToggle, ViewMode.COUNTS);
        modeSegButton.setToggleGroup(viewModeToggleGroup);
        viewModeToggleGroup.valueProperty()
                .addListener((observable, oldViewMode, newViewVode) -> controller.setViewMode(
                        newViewVode != null ? newViewVode : (oldViewMode != null ? oldViewMode : ViewMode.COUNTS)));

        controller.viewModeProperty().addListener(viewMode -> syncViewMode());
        syncViewMode();

        ActionUtils.configureButton(new SaveSnapshotAsReport(controller, notificationPane::getContent),
                snapShotButton);
        ActionUtils.configureButton(new UpdateDB(controller), updateDBButton);

        /////configure start and end pickers
        startLabel.setText(Bundle.ViewFrame_startLabel_text());
        endLabel.setText(Bundle.ViewFrame_endLabel_text());

        //suppress stacktraces on malformed input
        //TODO: should we do anything else? show a warning?
        startPicker.setParseErrorCallback(throwable -> null);
        endPicker.setParseErrorCallback(throwable -> null);

        //disable dates outside scope of case
        LocalDateDisabler localDateDisabler = new LocalDateDisabler();
        startPicker.setLocalDateTimeRangeCallback(localDateDisabler);
        endPicker.setLocalDateTimeRangeCallback(localDateDisabler);

        //prevent selection of (date/)times outside the scope of this case
        startPicker.setValueValidationCallback(new LocalDateTimeValidator(startPicker));
        endPicker.setValueValidationCallback(new LocalDateTimeValidator(endPicker));

        //setup rangeslider
        rangeSlider.setOpacity(.7);
        rangeSlider.setMin(0);
        rangeSlider.setBlockIncrement(1);
        rangeHistogramStack.getChildren().add(rangeSlider);

        /*
         * This padding attempts to compensates for the fact that the
         * rangeslider track doesn't extend to edge of node,and so the
         * histrogram doesn't quite line up with the rangeslider
         */
        histogramBox.setStyle("   -fx-padding: 0,0.5em,0,.5em; "); //NON-NLS

        //configure zoom buttons
        zoomMenuButton.getItems().clear();
        for (ZoomRanges zoomRange : ZoomRanges.values()) {
            zoomMenuButton.getItems().add(ActionUtils.createMenuItem(
                    new Action(zoomRange.getDisplayName(), event -> controller.pushPeriod(zoomRange.getPeriod()))));
        }
        zoomMenuButton.setText(Bundle.ViewFrame_zoomMenuButton_text());
        ActionUtils.configureButton(new ZoomOut(controller), zoomOutButton);
        ActionUtils.configureButton(new ZoomIn(controller), zoomInButton);

        //register for EventBus events (tags)
        filteredEvents.registerForEvents(this);

        //listen for changes in the time range / zoom params
        TimeLineController.getTimeZone().addListener(timeZoneProp -> refreshTimeUI());
        filteredEvents.timeRangeProperty().addListener(timeRangeProp -> refreshTimeUI());
        filteredEvents.zoomParametersProperty().addListener(zoomListener);
        refreshTimeUI(); //populate the view

        refreshHistorgram();

    }

    /**
     * Handle TagsUpdatedEvents by marking that the view needs to be refreshed.
     *
     * NOTE: This ViewFrame must be registered with the filteredEventsModel's
     * EventBus in order for this handler to be invoked.
     *
     * @param event The TagsUpdatedEvent to handle.
     */
    @Subscribe
    public void handleTimeLineTagUpdate(TagsUpdatedEvent event) {
        hostedView.setOutOfDate();
        Platform.runLater(() -> {
            if (notificationPane.isShowing() == false) {
                notificationPane.getActions().setAll(new Refresh());
                notificationPane.show(Bundle.ViewFrame_tagsAddedOrDeleted(), new ImageView(INFORMATION));
            }
        });
    }

    /**
     * Handle a RefreshRequestedEvent from the events model by clearing the
     * refresh notification.
     *
     * NOTE: This ViewFrame must be registered with the filteredEventsModel's
     * EventBus in order for this handler to be invoked.
     *
     * @param event The RefreshRequestedEvent to handle.
     */
    @Subscribe
    public void handleRefreshRequested(RefreshRequestedEvent event) {
        Platform.runLater(() -> {
            if (Bundle.ViewFrame_tagsAddedOrDeleted().equals(notificationPane.getText())) {
                notificationPane.hide();
            }
        });
    }

    /**
     * Handle a DBUpdatedEvent from the events model by refreshing the view.
     *
     * NOTE: This ViewFrame must be registered with the filteredEventsModel's
     * EventBus in order for this handler to be invoked.
     *
     * @param event The DBUpdatedEvent to handle.
     */
    @Subscribe
    public void handleDBUpdated(DBUpdatedEvent event) {
        hostedView.refresh();
        refreshHistorgram();
        Platform.runLater(notificationPane::hide);
    }

    /**
     * Handle a DataSourceAddedEvent from the events model by showing a
     * notification.
     *
     * NOTE: This ViewFrame must be registered with the filteredEventsModel's
     * EventBus in order for this handler to be invoked.
     *
     * @param event The DataSourceAddedEvent to handle.
     */
    @Subscribe
    @NbBundle.Messages({ "# {0} - datasource name",
            "ViewFrame.notification.newDataSource={0} has been added as a new datasource.  The Timeline DB may be out of date." })
    public void handlDataSourceAdded(DataSourceAddedEvent event) {
        Platform.runLater(() -> {
            notificationPane.getActions().setAll(new UpdateDB(controller));
            notificationPane.show(Bundle.ViewFrame_notification_newDataSource(event.getDataSource().getName()),
                    new ImageView(WARNING));
        });
    }

    /**
     * Handle a DataSourceAnalysisCompletedEvent from the events modelby showing
     * a notification.
     *
     * NOTE: This ViewFrame must be registered with the filteredEventsModel's
     * EventBus in order for this handler to be invoked.
     *
     * @param event The DataSourceAnalysisCompletedEvent to handle.
     */
    @Subscribe
    @NbBundle.Messages({ "# {0} - datasource name",
            "ViewFrame.notification.analysisComplete=Analysis has finished for {0}.  The Timeline DB may be out of date." })
    public void handleAnalysisCompleted(DataSourceAnalysisCompletedEvent event) {
        Platform.runLater(() -> {
            notificationPane.getActions().setAll(new UpdateDB(controller));
            notificationPane.show(Bundle.ViewFrame_notification_analysisComplete(event.getDataSource().getName()),
                    new ImageView(WARNING));
        });
    }

    /**
     * Refresh the Histogram to represent the current state of the DB.
     */
    @NbBundle.Messages({ "ViewFrame.histogramTask.title=Rebuilding Histogram",
            "ViewFrame.histogramTask.preparing=Preparing", "ViewFrame.histogramTask.resetUI=Resetting UI",
            "ViewFrame.histogramTask.queryDb=Querying FB", "ViewFrame.histogramTask.updateUI2=Updating UI" })
    synchronized private void refreshHistorgram() {
        if (histogramTask != null) {
            histogramTask.cancel(true);
        }

        histogramTask = new LoggedTask<Void>(Bundle.ViewFrame_histogramTask_title(), true) {
            private final Lighting lighting = new Lighting();

            @Override
            protected Void call() throws Exception {

                updateMessage(ViewFrame_histogramTask_preparing());

                long max = 0;
                final RangeDivisionInfo rangeInfo = RangeDivisionInfo
                        .getRangeDivisionInfo(filteredEvents.getSpanningInterval());
                final long lowerBound = rangeInfo.getLowerBound();
                final long upperBound = rangeInfo.getUpperBound();
                Interval timeRange = new Interval(new DateTime(lowerBound, TimeLineController.getJodaTimeZone()),
                        new DateTime(upperBound, TimeLineController.getJodaTimeZone()));

                //extend range to block bounderies (ie day, month, year)
                int p = 0; // progress counter

                //clear old data, and reset ranges and series
                Platform.runLater(() -> {
                    updateMessage(ViewFrame_histogramTask_resetUI());

                });

                ArrayList<Long> bins = new ArrayList<>();

                DateTime start = timeRange.getStart();
                while (timeRange.contains(start)) {
                    if (isCancelled()) {
                        return null;
                    }
                    DateTime end = start.plus(rangeInfo.getPeriodSize().getPeriod());
                    final Interval interval = new Interval(start, end);
                    //increment for next iteration

                    start = end;

                    updateMessage(ViewFrame_histogramTask_queryDb());
                    //query for current range
                    long count = filteredEvents.getEventCounts(interval).values().stream().mapToLong(Long::valueOf)
                            .sum();
                    bins.add(count);

                    max = Math.max(count, max);

                    final double fMax = Math.log(max);
                    final ArrayList<Long> fbins = new ArrayList<>(bins);
                    Platform.runLater(() -> {
                        updateMessage(ViewFrame_histogramTask_updateUI2());

                        histogramBox.getChildren().clear();

                        for (Long bin : fbins) {
                            if (isCancelled()) {
                                break;
                            }
                            Region bar = new Region();
                            //scale them to fit in histogram height
                            bar.prefHeightProperty()
                                    .bind(histogramBox.heightProperty().multiply(Math.log(bin)).divide(fMax));
                            bar.setMaxHeight(USE_PREF_SIZE);
                            bar.setMinHeight(USE_PREF_SIZE);
                            bar.setBackground(GRAY_BACKGROUND);
                            bar.setOnMouseEntered((MouseEvent event) -> {
                                Tooltip.install(bar, new Tooltip(bin.toString()));
                            });
                            bar.setEffect(lighting);
                            //they each get equal width to fill the histogram horizontally
                            HBox.setHgrow(bar, Priority.ALWAYS);
                            histogramBox.getChildren().add(bar);
                        }
                    });
                }
                return null;
            }

        };
        new Thread(histogramTask).start();
        controller.monitorTask(histogramTask);
    }

    /**
     * Refresh the time selection UI to match the current zoom parameters.
     */
    private void refreshTimeUI() {
        RangeDivisionInfo rangeDivisionInfo = RangeDivisionInfo
                .getRangeDivisionInfo(filteredEvents.getSpanningInterval());
        final long minTime = rangeDivisionInfo.getLowerBound();
        final long maxTime = rangeDivisionInfo.getUpperBound();

        long startMillis = filteredEvents.getTimeRange().getStartMillis();
        long endMillis = filteredEvents.getTimeRange().getEndMillis();

        if (minTime > 0 && maxTime > minTime) {
            Platform.runLater(() -> {
                startPicker.localDateTimeProperty().removeListener(startListener);
                endPicker.localDateTimeProperty().removeListener(endListener);
                rangeSlider.highValueChangingProperty().removeListener(rangeSliderListener);
                rangeSlider.lowValueChangingProperty().removeListener(rangeSliderListener);

                rangeSlider.setMax((maxTime - minTime));

                rangeSlider.setLowValue(startMillis - minTime);
                rangeSlider.setHighValue(endMillis - minTime);
                startPicker.setLocalDateTime(epochMillisToLocalDateTime(startMillis));
                endPicker.setLocalDateTime(epochMillisToLocalDateTime(endMillis));

                rangeSlider.highValueChangingProperty().addListener(rangeSliderListener);
                rangeSlider.lowValueChangingProperty().addListener(rangeSliderListener);
                startPicker.localDateTimeProperty().addListener(startListener);
                endPicker.localDateTimeProperty().addListener(endListener);
            });
        }
    }

    /**
     * Sync up the view shown in the UI to the one currently active according to
     * the controller. Swaps out the hosted AbstractTimelineView for a new one
     * of the correct type.
     */
    private void syncViewMode() {
        ViewMode newViewMode = controller.getViewMode();

        //clear out old view.
        if (hostedView != null) {
            hostedView.dispose();
        }

        //Set a new AbstractTimeLineView as the one hosted by this ViewFrame.
        switch (newViewMode) {
        case LIST:
            hostedView = new ListViewPane(controller);
            //TODO: should remove listeners from events tree
            break;
        case COUNTS:
            hostedView = new CountsViewPane(controller);
            //TODO: should remove listeners from events tree
            break;
        case DETAIL:
            DetailViewPane detailViewPane = new DetailViewPane(controller);
            //link events tree to detailview instance.
            detailViewPane.setHighLightedEvents(eventsTree.getSelectedEvents());
            eventsTree.setDetailViewPane(detailViewPane);
            hostedView = detailViewPane;
            break;
        default:
            throw new IllegalArgumentException("Unknown ViewMode: " + newViewMode.toString());//NON-NLS
        }
        controller.registerForEvents(hostedView);

        viewModeToggleGroup.setValue(newViewMode); //this selects the right toggle automatically

        //configure settings and time navigation nodes
        setViewSettingsControls(hostedView.getSettingsControls());
        setTimeNavigationControls(
                hostedView.hasCustomTimeNavigationControls() ? hostedView.getTimeNavigationControls()
                        : defaultTimeNavigationNodes);

        //do further setup of  new view.
        ActionUtils.configureButton(new Refresh(), refreshButton);//configure new refresh action for new view
        hostedView.refresh();
        notificationPane.setContent(hostedView);
        //listen to has events property and show "dialog" if it is false.
        hostedView.hasVisibleEventsProperty().addListener(hasEvents -> {
            notificationPane.setContent(hostedView.hasVisibleEvents() ? hostedView
                    : new StackPane(hostedView, NO_EVENTS_BACKGROUND,
                            new NoEventsDialog(() -> notificationPane.setContent(hostedView))));
        });
    }

    /**
     * Show the given List of Nodes in the top ToolBar. Replaces any settings
     * Nodes that may have previously been set with the given List of Nodes.
     *
     * @param newSettingsNodes The Nodes to show in the ToolBar.
     */
    @ThreadConfined(type = ThreadConfined.ThreadType.JFX)
    private void setViewSettingsControls(List<Node> newSettingsNodes) {
        toolBar.getItems().removeAll(this.settingsNodes); //remove old nodes
        this.settingsNodes.setAll(newSettingsNodes);
        toolBar.getItems().addAll(SETTINGS_TOOLBAR_INSERTION_INDEX, settingsNodes);
    }

    /**
     * Show the given List of Nodes in the time range ToolBar. Replaces any
     * Nodes that may have previously been set with the given List of Nodes.
     *
     * @param timeNavigationNodes The Nodes to show in the time range ToolBar.
     */
    @ThreadConfined(type = ThreadConfined.ThreadType.JFX)
    private void setTimeNavigationControls(List<Node> timeNavigationNodes) {
        timeRangeToolBar.getItems().removeAll(this.timeNavigationNodes); //remove old nodes
        this.timeNavigationNodes.setAll(timeNavigationNodes);
        timeRangeToolBar.getItems().addAll(TIME_TOOLBAR_INSERTION_INDEX, timeNavigationNodes);
    }

    @NbBundle.Messages("NoEventsDialog.titledPane.text=No Visible Events")
    private class NoEventsDialog extends StackPane {

        @FXML
        private TitledPane titledPane;
        @FXML
        private Button backButton;
        @FXML
        private Button resetFiltersButton;
        @FXML
        private Button dismissButton;
        @FXML
        private Button zoomButton;
        @FXML
        private Label noEventsDialogLabel;

        private final Runnable closeCallback;

        private NoEventsDialog(Runnable closeCallback) {
            this.closeCallback = closeCallback;
            FXMLConstructor.construct(this, "NoEventsDialog.fxml"); //NON-NLS
        }

        @FXML
        @NbBundle.Messages("ViewFrame.noEventsDialogLabel.text=There are no events visible with the current zoom / filter settings.")
        void initialize() {
            assert resetFiltersButton != null : "fx:id=\"resetFiltersButton\" was not injected: check your FXML file 'NoEventsDialog.fxml'."; //NON-NLS
            assert dismissButton != null : "fx:id=\"dismissButton\" was not injected: check your FXML file 'NoEventsDialog.fxml'."; //NON-NLS
            assert zoomButton != null : "fx:id=\"zoomButton\" was not injected: check your FXML file 'NoEventsDialog.fxml'."; //NON-NLS

            titledPane.setText(Bundle.NoEventsDialog_titledPane_text());
            noEventsDialogLabel.setText(Bundle.ViewFrame_noEventsDialogLabel_text());

            dismissButton.setOnAction(actionEvent -> closeCallback.run());

            ActionUtils.configureButton(new ZoomToEvents(controller), zoomButton);
            ActionUtils.configureButton(new Back(controller), backButton);
            ActionUtils.configureButton(new ResetFilters(controller), resetFiltersButton);
        }
    }

    /**
     * Base class for listeners that listen to a LocalDateTimeTextField and push
     * the selected LocalDateTime as start/end to the timelinecontroller.
     */
    private class PickerListener implements InvalidationListener {

        private final BiFunction<Interval, Long, Interval> intervalMapper;
        private final Supplier<LocalDateTimeTextField> pickerSupplier;

        PickerListener(Supplier<LocalDateTimeTextField> pickerSupplier,
                BiFunction<Interval, Long, Interval> intervalMapper) {
            this.pickerSupplier = pickerSupplier;
            this.intervalMapper = intervalMapper;
        }

        @Override
        public void invalidated(Observable observable) {
            LocalDateTime pickerTime = pickerSupplier.get().getLocalDateTime();
            if (pickerTime != null) {
                controller.pushTimeRange(intervalMapper.apply(filteredEvents.timeRangeProperty().get(),
                        localDateTimeToEpochMilli(pickerTime)));
                Platform.runLater(ViewFrame.this::refreshTimeUI);
            }
        }
    }

    /**
     * callback that disabled date/times outside the span of the current case.
     */
    private class LocalDateDisabler implements Callback<LocalDateTimePicker.LocalDateTimeRange, Void> {

        @Override
        public Void call(LocalDateTimePicker.LocalDateTimeRange viewedRange) {
            startPicker.disabledLocalDateTimes().clear();
            endPicker.disabledLocalDateTimes().clear();

            //all events in the case are contained in this interval
            Interval spanningInterval = filteredEvents.getSpanningInterval();
            long spanStartMillis = spanningInterval.getStartMillis();
            long spaneEndMillis = spanningInterval.getEndMillis();

            LocalDate rangeStartLocalDate = viewedRange.getStartLocalDateTime().toLocalDate();
            LocalDate rangeEndLocalDate = viewedRange.getEndLocalDateTime().toLocalDate().plusDays(1);
            //iterate over days of the displayed range and disable ones not in spanning interval
            for (LocalDate dt = rangeStartLocalDate; false == dt.isAfter(rangeEndLocalDate); dt = dt.plusDays(1)) {
                long startOfDay = dt.atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli();
                long endOfDay = dt.plusDays(1).atStartOfDay().toInstant(ZoneOffset.UTC).toEpochMilli();
                //if no part of day is within spanning interval, add that date the list of disabled dates.
                if (endOfDay < spanStartMillis || startOfDay > spaneEndMillis) {
                    startPicker.disabledLocalDateTimes().add(dt.atStartOfDay());
                    endPicker.disabledLocalDateTimes().add(dt.atStartOfDay());
                }
            }
            return null;
        }
    }

    /**
     * Callback that validates that selected date/times are in the spanning
     * interval for this case, and resets the textbox if invalid date/time was
     * entered.
     */
    private class LocalDateTimeValidator implements Callback<LocalDateTime, Boolean> {

        /**
         * picker to reset if invalid info was entered
         */
        private final LocalDateTimeTextField picker;

        LocalDateTimeValidator(LocalDateTimeTextField picker) {
            this.picker = picker;
        }

        @Override
        public Boolean call(LocalDateTime param) {
            long epochMilli = localDateTimeToEpochMilli(param);
            if (filteredEvents.getSpanningInterval().contains(epochMilli)) {
                return true;
            } else {
                if (picker.isPickerShowing() == false) {
                    //if the user typed an in valid date, reset the text box to the selected date.
                    picker.setDisplayedLocalDateTime(picker.getLocalDateTime());
                }
                return false;
            }
        }
    }

    /**
     * Action that refreshes the View.
     */
    private class Refresh extends Action {

        @NbBundle.Messages({ "ViewFrame.refresh.text=Refresh View",
                "ViewFrame.refresh.longText=Refresh the view to include information that is in the DB but not displayed, such as newly updated tags." })
        Refresh() {
            super(Bundle.ViewFrame_refresh_text());
            setLongText(Bundle.ViewFrame_refresh_longText());
            setGraphic(new ImageView(REFRESH));
            setEventHandler(actionEvent -> filteredEvents.postRefreshRequest());
            disabledProperty().bind(hostedView.outOfDateProperty().not());
        }
    }
}