/*
* Geotoolkit - An Open Source Java GIS Toolkit
* http://www.geotoolkit.org
*
* (C) 2014, Geomatys
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation;
* version 2.1 of the License.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*/
package org.geotoolkit.gui.javafx.contexttree;
import java.util.Collections;
import java.util.List;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ChangeListener;
import javafx.beans.value.ObservableValue;
import javafx.collections.FXCollections;
import javafx.collections.ListChangeListener;
import javafx.collections.ObservableList;
import javafx.event.EventHandler;
import javafx.geometry.Bounds;
import javafx.geometry.Point3D;
import javafx.scene.control.ContextMenu;
import javafx.scene.control.Label;
import javafx.scene.control.MenuItem;
import javafx.scene.control.SelectionMode;
import javafx.scene.control.SeparatorMenuItem;
import javafx.scene.control.TreeItem;
import javafx.scene.control.TreeTableRow;
import javafx.scene.control.TreeTableView;
import javafx.scene.input.DataFormat;
import javafx.scene.input.DragEvent;
import javafx.scene.input.Dragboard;
import javafx.scene.input.MouseEvent;
import javafx.scene.input.PickResult;
import javafx.scene.input.TransferMode;
import javafx.scene.layout.BorderPane;
import javafx.util.Callback;
import org.geotoolkit.gui.javafx.util.FXUtilities;
import org.geotoolkit.map.MapContext;
import org.geotoolkit.map.MapItem;
import org.geotoolkit.map.MapLayer;
/**
*
* @author Johann Sorel (Geomatys)
*/
public class FXMapContextTree extends BorderPane {
private static final double BORDER_DELTA = 4.0;
private static final DataFormat MAPITEM_FORMAT = new DataFormat("contextItem");
/**
* Flag indicating that a node as been dragged to the bottom of target node.
*/
public static final int DRAGGED_BELOW = -1;
/**
* Indicates source node has been dragged into target node.
*/
public static final int DRAGGED_INTO = 0;
/**
* Indicates that source node as been dragged to the upper bound of target
* node.
*/
public static final int DRAGGED_ABOVE = 1;
/**
* CSS Style class applied when a node is dragged on the top border of
* another one.
*/
public static final String ABOVE_CSS = "dragged-above";
/**
* CSS Style class applied when a node is dragged on the bottom border of
* another one.
*/
public static final String BELOW_CSS = "dragged-below";
/**
* CSS Style class applied when a node is dragged over another one.
*/
public static final String OVER_CSS = "dragged-over";
private final ObservableList<Object> menuItems = FXCollections.observableArrayList();
private final TreeTableView<MapItem> treetable = new TreeTableView();
private final ObjectProperty<MapContext> itemProperty = new SimpleObjectProperty<>();
public FXMapContextTree() {
this(null);
}
public FXMapContextTree(MapContext item) {
setCenter(treetable);
//configure treetable
treetable.getColumns().add(new MapItemNameColumn());
treetable.getColumns().add(new MapItemGlyphColumn());
treetable.getColumns().add(new MapItemVisibleColumn());
treetable.setTableMenuButtonVisible(false);
treetable.setEditable(true);
treetable.setContextMenu(new ContextMenu());
treetable.setPlaceholder(new Label(""));
treetable.setRowFactory(new Callback<TreeTableView<MapItem>, TreeTableRow<MapItem>>() {
@Override
public TreeTableRow<MapItem> call(TreeTableView<MapItem> param) {
final TreeTableRow row = new TreeTableRow();
initDragAndDrop(row);
return row;
}
});
treetable.getStylesheets().add("org/geotoolkit/gui/javafx/parameter/parameters.css");
//this will cause the column width to fit the view area
treetable.setColumnResizePolicy(TreeTableView.CONSTRAINED_RESIZE_POLICY);
final ContextMenu menu = new ContextMenu();
treetable.setContextMenu(menu);
//update context menu based on selected items
treetable.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE);
treetable.getSelectionModel().getSelectedItems().addListener(new ListChangeListener() {
@Override
public void onChanged(ListChangeListener.Change change) {
final ObservableList items = menu.getItems();
items.clear();
final List<? extends TreeItem> selection = FXUtilities.getSelectionItems(treetable);
for (int i = 0, n = menuItems.size(); i < n; i++) {
final Object candidate = menuItems.get(i);
if (candidate instanceof TreeMenuItem) {
final MenuItem mc = ((TreeMenuItem) candidate).init(selection);
if (mc != null)
items.add(mc);
} else if (candidate instanceof SeparatorMenuItem) {
//special case, we don't want any separator at the start or end
//or 2 succesive separators
if (i == 0 || i == n - 1 || items.isEmpty())
continue;
if (items.get(items.size() - 1) instanceof SeparatorMenuItem) {
continue;
}
items.add((SeparatorMenuItem) candidate);
} else if (candidate instanceof MenuItem) {
items.add((MenuItem) candidate);
}
}
//special case, we don't want any separator at the start or end
if (!items.isEmpty()) {
if (items.get(0) instanceof SeparatorMenuItem) {
items.remove(0);
}
if (!items.isEmpty()) {
final int idx = items.size() - 1;
if (items.get(idx) instanceof SeparatorMenuItem) {
items.remove(idx);
}
}
}
}
});
treetable.setShowRoot(true);
itemProperty.addListener(new ChangeListener<MapItem>() {
@Override
public void changed(ObservableValue<? extends MapItem> observable, MapItem oldValue, MapItem newValue) {
if (newValue == null) {
treetable.setRoot(null);
} else {
treetable.setRoot(new TreeMapItem(newValue));
}
}
});
setMapItem(item);
}
/**
* Configure ability to move a row into the tree view.
*
* @param row to configure.
*/
private void initDragAndDrop(final TreeTableRow<MapItem> row) {
row.setOnDragDetected(new EventHandler<MouseEvent>() {
@Override
public void handle(MouseEvent event) {
final int selection = treetable.getSelectionModel().getSelectedIndex();
final Dragboard db = treetable.startDragAndDrop(TransferMode.MOVE);
db.setContent(Collections.singletonMap(MAPITEM_FORMAT, selection));
}
});
row.setOnDragOver(new EventHandler<DragEvent>() {
@Override
public void handle(DragEvent event) {
if (event.getDragboard().hasContent(MAPITEM_FORMAT)) {
event.acceptTransferModes(TransferMode.MOVE);
}
// Does not impact empty row style.
if (row.isEmpty() || row.getItem() == null) {
return;
}
row.getStyleClass().removeAll(ABOVE_CSS, BELOW_CSS, OVER_CSS);
switch (getDragPosition(event.getPickResult())) {
case DRAGGED_ABOVE:
row.getStyleClass().add(ABOVE_CSS);
break;
case DRAGGED_BELOW:
row.getStyleClass().add(BELOW_CSS);
break;
default:
row.getStyleClass().add(OVER_CSS);
}
}
});
row.setOnDragExited(new EventHandler<DragEvent>() {
@Override
public void handle(DragEvent event) {
row.getStyleClass().removeAll(ABOVE_CSS, BELOW_CSS, OVER_CSS);
}
});
row.setOnDragDropped(new EventHandler<DragEvent>() {
@Override
public void handle(DragEvent event) {
final Dragboard db = event.getDragboard();
boolean success = false;
conditions:
if (db.hasContent(MAPITEM_FORMAT)) {
final int index = (Integer) db.getContent(MAPITEM_FORMAT);
if (index >= 0) {
final MapItem root = treetable.getRoot() == null ? null : treetable.getRoot().getValue();
if (root == null)
break conditions;
ObservableList<TreeItem<MapItem>> movedRows = treetable.getSelectionModel().getSelectedItems();
final TreeItem<MapItem> targetRow = row.getTreeItem();
// Prevent moving a row in itself
if (targetRow != null) {
movedRows = movedRows.filtered(toMove -> !FXUtilities.isParent(toMove, targetRow));
}
if (movedRows.isEmpty())
break conditions;
final MapItem targetItem = row.getItem();
final MapItem targetParent = (targetRow == null || targetRow.getParent() == null ? null : targetRow.getParent().getValue());
final int dragPosition = getDragPosition(event.getPickResult());
for (final TreeItem<MapItem> movedRow : movedRows) {
final MapItem movedItem = movedRow.getValue();
final MapItem movedParent = (movedRow.getParent() == null ? null : movedRow.getParent().getValue());
// Root or null item dragged. Cannot move them.
if (movedItem == null || movedParent == null) {
continue;
}
movedParent.items().remove(movedItem);
if (targetItem == null) {
// Insert in empty row, should be at end of the tree.
root.items().add(0, movedItem);
} else if (targetParent == null) {
// Add directly on root.
root.items().add(movedItem);
} else {
switch (dragPosition) {
case DRAGGED_ABOVE:
targetParent.items().add(targetParent.items().indexOf(targetItem) + 1, movedItem);
break;
case DRAGGED_BELOW:
targetParent.items().add(targetParent.items().indexOf(targetItem), movedItem);
break;
default:
if (targetItem instanceof MapLayer) {
//insert as sibling
final int insertIndex = targetParent.items().indexOf(targetItem);
targetParent.items().add(insertIndex, movedItem);
} else {
//insert as children
targetItem.items().add(movedItem);
}
}
}
}
}
success = true;
}
// Clear selection to avoid random index selection on tree update.
treetable.getSelectionModel().clearSelection();
event.setDropCompleted(success);
}
});
}
/**
* Analyze if cursor is positionned on the upper or lower bound of target
* node.
*
* @param pick Result of proceed picking.
* @return {@link #DRAGGED_ABOVE} if picking is located on upper target
* bound, {@link #DRAGGED_BELOW} if it's on lower bound. Otherwise,
* {@link #DRAGGED_INTO} is sent back.
*/
private static int getDragPosition(final PickResult pick) {
final Bounds targetBounds = pick.getIntersectedNode().getBoundsInLocal();
final Point3D intersectedPoint = pick.getIntersectedPoint();
if (intersectedPoint.getY() <= targetBounds.getMinY() + BORDER_DELTA) {
return DRAGGED_ABOVE;
} else if (intersectedPoint.getY() >= targetBounds.getMaxY() - BORDER_DELTA) {
return DRAGGED_BELOW;
} else {
return DRAGGED_INTO;
}
}
public TreeTableView getTreetable() {
return treetable;
}
/**
* This list can contain MenuItem of TreeMenuItem.
*
* @return ObservableList of contextual menu items.
*/
public ObservableList<Object> getMenuItems() {
return menuItems;
}
public ObjectProperty<MapContext> mapItemProperty() {
return itemProperty;
}
public MapContext getMapItem() {
return itemProperty.get();
}
public void setMapItem(MapContext mapItem) {
itemProperty.set(mapItem);
}
}