/* * 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 com.google.common.math.DoubleMath; import java.math.RoundingMode; 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.Objects; import java.util.Set; import java.util.SortedSet; import java.util.TreeSet; import java.util.concurrent.ConcurrentSkipListSet; import java.util.function.Consumer; import java.util.function.Function; import java.util.logging.Level; import java.util.stream.Collectors; import javafx.application.Platform; 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.ListChangeListener; import javafx.event.ActionEvent; 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.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.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.ChronoFieldListCell; 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 a 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); private static final int DEFAULT_ROW_HEIGHT = 24; @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; /** * Since TableView does not expose what cells/items are visible, we track * them in this set. It is sorted by index in the TableView's model. */ private final SortedSet<CombinedEvent> visibleEvents; private final TimeLineController controller; private final SleuthkitCase sleuthkitCase; private final TagsManager tagsManager; /** * Listener attached to the table's selection model that pushes that * selection to the controller. Maps from Combined event in table to EventID * in controller via CombinedEvent.getRepresentativeEventID. */ private final ListChangeListener<CombinedEvent> selectedEventListener = new ListChangeListener<CombinedEvent>() { @Override public void onChanged(ListChangeListener.Change<? extends CombinedEvent> c) { controller.selectEventIDs(table.getSelectionModel().getSelectedItems().stream() .filter(Objects::nonNull) .map(CombinedEvent::getRepresentativeEventID) .collect(Collectors.toSet())); } }; /** * 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 //configure scroll controls 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 table 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()); } }); // use listener to keep controller selection in sync with table selection. table.getSelectionModel().getSelectedItems().addListener(selectedEventListener); table.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); selectEvents(controller.getSelectedEventIDs()); //grab initial selection } /** * 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); } /** * Set the combined events that are selected in this view. * * @param selectedEventIDs The events that should be selected. */ void selectEvents(Collection<Long> selectedEventIDs) { if (selectedEventIDs.isEmpty()) { //this is the final selection, so we don't need to mess with the listener table.getSelectionModel().clearSelection(); } else { /* * Changes in the table selection are propogated to the controller * by a listener. There is no API on TableView's selection model to * clear the selection and select multiple rows as one "action". * Therefore we clear the selection and then make the new selection, * but we don't want this intermediate state of no selection to be * pushed to the controller as it interferes with maintaining the * right selection. To avoid notifying the controller, we remove the * listener, clear the selection, then re-attach it. */ table.getSelectionModel().getSelectedItems().removeListener(selectedEventListener); table.getSelectionModel().clearSelection(); table.getSelectionModel().getSelectedItems().addListener(selectedEventListener); //find the indices of the CombinedEvents that will be selected int[] selectedIndices = table.getItems().stream() .filter(combinedEvent -> Collections.disjoint(combinedEvent.getEventIDs(), selectedEventIDs) == false) .mapToInt(table.getItems()::indexOf) .toArray(); //select indices and scroll to the first one if (selectedIndices.length > 0) { Integer firstSelectedIndex = selectedIndices[0]; table.getSelectionModel().selectIndices(firstSelectedIndex, selectedIndices); scrollTo(firstSelectedIndex); table.requestFocus(); //grab focus so selection is clearer to user } } } /** * Get the time navigation controls that this ListTimeline's parent * ListViewPane will provide to its host ViewFrame. * * @return A List of time navigation controls in the from of JavaFX scene * graph Nodes. */ List<Node> getTimeNavigationControls() { return Collections.singletonList(navControls); } /** * Scroll the table to the given index (if it is not already visible) and * focus it. * * @param index The index of the item that should be scrolled in to view and * focused. */ private void scrollToAndFocus(Integer index) { table.requestFocus(); scrollTo(index); table.getFocusModel().focus(index); } /** * Scroll the table to the given index (if it is not already visible). * * @param index The index of the item that should be scrolled in to view. */ private void scrollTo(Integer index) { if (visibleEvents.contains(table.getItems().get(index)) == false) { table.scrollTo(DoubleMath.roundToInt(index - ((table.getHeight() / DEFAULT_ROW_HEIGHT)) / 2, RoundingMode.HALF_EVEN)); } } /** * 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 ScrollToFirst extends org.controlsfx.control.action.Action { ScrollToFirst() { super("", new Consumer<ActionEvent>() { //do not make this a lambda function see issue 2147 @Override public void accept(ActionEvent 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("", new Consumer<ActionEvent>() { //do not make this a lambda function see issue 2147 @Override public void accept(ActionEvent 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("", new Consumer<ActionEvent>() { //do not make this a lambda function see issue 2147 @Override public void accept(ActionEvent 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("", new Consumer<ActionEvent>() { //do not make this a lambda function see issue 2147 @Override public void accept(ActionEvent 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)); } } }