/*
* Copyright 2016 MovingBlocks
*
* 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.terasology.rendering.nui.widgets;
import com.google.common.base.Preconditions;
import com.google.common.collect.Lists;
import org.terasology.input.Keyboard;
import org.terasology.input.MouseInput;
import org.terasology.input.device.KeyboardDevice;
import org.terasology.math.geom.Rect2i;
import org.terasology.math.geom.Vector2i;
import org.terasology.rendering.nui.BaseInteractionListener;
import org.terasology.rendering.nui.Canvas;
import org.terasology.rendering.nui.Color;
import org.terasology.rendering.nui.CoreWidget;
import org.terasology.rendering.nui.LayoutConfig;
import org.terasology.rendering.nui.UIWidget;
import org.terasology.rendering.nui.databinding.Binding;
import org.terasology.rendering.nui.databinding.DefaultBinding;
import org.terasology.rendering.nui.events.NUIKeyEvent;
import org.terasology.rendering.nui.events.NUIMouseClickEvent;
import org.terasology.rendering.nui.events.NUIMouseDoubleClickEvent;
import org.terasology.rendering.nui.events.NUIMouseDragEvent;
import org.terasology.rendering.nui.events.NUIMouseOverEvent;
import org.terasology.rendering.nui.events.NUIMouseReleaseEvent;
import org.terasology.rendering.nui.itemRendering.ItemRenderer;
import org.terasology.rendering.nui.itemRendering.ToStringTextRenderer;
import org.terasology.rendering.nui.widgets.treeView.Tree;
import org.terasology.rendering.nui.widgets.treeView.TreeKeyEventListener;
import org.terasology.rendering.nui.widgets.treeView.TreeModel;
import org.terasology.rendering.nui.widgets.treeView.TreeMouseClickListener;
import org.terasology.rendering.nui.widgets.treeView.TreeViewState;
import java.util.List;
/**
* A Tree View widget. Presents a hierarchical view of items, visualised by indentation.
*
* @param <T> Type of objects stored in the underlying tree.
*/
public class UITreeView<T> extends CoreWidget {
public enum MouseOverType {
TOP,
CENTER,
BOTTOM
}
// Canvas parts.
private static final String EXPAND_BUTTON = "expand-button";
private static final String TREE_NODE = "tree-node";
// Canvas modes.
private static final String CONTRACT_MODE = "contract";
private static final String CONTRACT_HOVER_MODE = "contract-hover";
private static final String EXPAND_MODE = "expand";
private static final String EXPAND_HOVER_MODE = "expand-hover";
private static final String HOVER_DISABLED_MODE = "hover-disabled";
/**
* The horizontal indentation, in pixels, corresponding to one level in the tree.
*/
@LayoutConfig
private Binding<Integer> levelIndent = new DefaultBinding<>(25);
/**
* The underlying tree model - a wrapper around a {@code Tree<T>}.
*/
private Binding<TreeModel<T>> model = new DefaultBinding<>(new TreeModel<>());
/**
* The state of the tree - includes session-specific information about the currently selected node, copied node etc.
* See the documentation {@link TreeViewState} for information concerning specific state variables.
*/
private TreeViewState<T> state = new TreeViewState<>();
/**
* The item renderer used for drawing the values of the tree.
*/
private ItemRenderer<T> itemRenderer = new ToStringTextRenderer<>();
/**
* Listeners fired when a node is clicked. Internally instantiated.
*/
private final List<TreeViewListenerSet> treeViewListenerSets = Lists.newArrayList();
/**
* Listeners fired when the expand/contract button is clicked.
*/
private final List<ExpandButtonInteractionListener> expandListeners = Lists.newArrayList();
/**
* Listeners fired when the model of the tree is updated.
*/
private List<UpdateListener> updateListeners = Lists.newArrayList();
/**
* Listeners fired when a node is clicked. Can be subscribed to.
*/
private List<TreeMouseClickListener> nodeClickListeners = Lists.newArrayList();
/**
* Listeners fired when a node is double-clicked. Can be subscribed to.
*/
private List<TreeMouseClickListener> nodeDoubleClickListeners = Lists.newArrayList();
/**
* Listeners fired when a key is pressed. Can be subscribed to.
*/
private List<TreeKeyEventListener> keyEventListeners = Lists.newArrayList();
public UITreeView() {
}
public UITreeView(String id) {
super(id);
}
@Override
public void onDraw(Canvas canvas) {
updateListeners();
canvas.setPart(TREE_NODE);
int currentHeight = 0;
for (int i = 0; i < model.get().getNodeCount(); i++) {
Tree<T> node = model.get().getNode(i);
TreeViewListenerSet treeViewListenerSet = treeViewListenerSets.get(i);
ExpandButtonInteractionListener buttonListener = expandListeners.get(i);
// Calculate the node's height and overall region.
int nodeHeight = canvas.getCurrentStyle().getMargin()
.grow(itemRenderer.getPreferredSize(node.getValue(), canvas).addX(node.getDepth() * levelIndent.get()))
.getY();
Rect2i nodeRegion = Rect2i.createFromMinAndSize((node.getDepth() + 1) * levelIndent.get(),
currentHeight,
canvas.size().x - (node.getDepth() + 1) * levelIndent.get(),
nodeHeight);
// Draw the expand/contract button.
if (!node.isLeaf()) {
canvas.setPart(EXPAND_BUTTON);
setButtonMode(canvas, node, buttonListener);
Rect2i buttonRegion = Rect2i.createFromMinAndSize(node.getDepth() * levelIndent.get(),
currentHeight,
levelIndent.get(),
nodeHeight);
drawButton(canvas, buttonRegion, buttonListener);
canvas.setPart(TREE_NODE);
}
if (state.getSelectedIndex() != null && state.getSelectedIndex() == i
&& state.getAlternativeWidget() != null) {
//Draw an alternative widget in place of the node (with the same size).
canvas.drawWidget(state.getAlternativeWidget(), nodeRegion);
currentHeight += nodeHeight;
} else {
// Draw the node itself.
setNodeMode(canvas, node, treeViewListenerSet);
drawNode(canvas, nodeRegion, node, treeViewListenerSet);
currentHeight += nodeHeight;
// Draw the dragging hints if the current node is a drag&drop target.
if (state.getMouseOverIndex() != null && state.getMouseOverIndex() == i) {
drawDragHint(canvas, nodeRegion);
}
}
}
}
@Override
public Vector2i getPreferredContentSize(Canvas canvas, Vector2i sizeHint) {
canvas.setPart(TREE_NODE);
if (model.get().getNodeCount() == 0) {
return new Vector2i();
}
model.get().setEnumerateExpandedOnly(false);
Vector2i result = new Vector2i();
for (int i = 0; i < model.get().getNodeCount(); i++) {
Tree<T> node = model.get().getNode(i);
Vector2i preferredSize = canvas.getCurrentStyle().getMargin()
.grow(itemRenderer.getPreferredSize(node.getValue(), canvas)
.addX(node.getDepth() * levelIndent.get()));
result.x = Math.max(result.x, preferredSize.x);
result.y += preferredSize.y;
}
model.get().setEnumerateExpandedOnly(true);
// Account for the expand/contract button!
result.addX(levelIndent.get());
return result;
}
@Override
public void update(float delta) {
super.update(delta);
if (state.getAlternativeWidget() != null) {
state.getAlternativeWidget().update(delta);
}
}
@Override
public boolean onKeyEvent(NUIKeyEvent event) {
for (TreeKeyEventListener listener : keyEventListeners) {
listener.onKeyEvent(event);
}
if (event.isDown()) {
int id = event.getKey().getId();
KeyboardDevice keyboard = event.getKeyboard();
boolean ctrlDown = keyboard.isKeyDown(Keyboard.KeyId.RIGHT_CTRL) || keyboard.isKeyDown(Keyboard.KeyId.LEFT_CTRL);
if (id == Keyboard.KeyId.UP || id == Keyboard.KeyId.DOWN) {
// Up/Down: change a node's position within the parent node.
return moveSelected(id);
} else if (id == Keyboard.KeyId.DELETE) {
// Delete: remove a node (and all its' children).
return removeSelected();
} else if (ctrlDown && id == Keyboard.KeyId.C) {
// Ctrl+C: copy a selected node.
if (state.getSelectedIndex() != null) {
copy(model.get().getNode(state.getSelectedIndex()));
return true;
}
return false;
} else if (ctrlDown && id == Keyboard.KeyId.V) {
// Ctrl+V: paste the copied node as a child of the currently selected node.
if (state.getSelectedIndex() != null) {
paste(model.get().getNode(state.getSelectedIndex()));
return true;
}
return false;
} else {
return false;
}
}
return false;
}
public Integer getSelectedIndex() {
return state.getSelectedIndex();
}
public void setSelectedIndex(Integer index) {
state.setSelectedIndex(index);
}
public UIWidget getAlternativeWidget() {
return state.getAlternativeWidget();
}
public void setAlternativeWidget(UIWidget widget) {
state.setAlternativeWidget(widget);
}
public void fireUpdateListeners() {
state.setAlternativeWidget(null);
updateListeners.forEach(UpdateListener::onAction);
}
public void copy(Tree<T> node) {
state.setClipboard(node.copy());
}
public void paste(Tree<T> node) {
if (state.getClipboard() != null) {
node.addChild(state.getClipboard());
fireUpdateListeners();
}
}
public void delete(Tree<T> node) {
if (node.getParent() != null) {
node.getParent().removeChild(node);
fireUpdateListeners();
}
}
public TreeModel<T> getModel() {
return model.get();
}
public void setModel(Tree<T> root) {
setModel(new TreeModel<>(root));
}
public void setModel(TreeModel<T> newModel) {
model.set(newModel);
state.setAlternativeWidget(null);
state.setSelectedIndex(null);
}
public void setItemRenderer(ItemRenderer<T> itemRenderer) {
this.itemRenderer = itemRenderer;
}
public void subscribeTreeViewUpdate(UpdateListener listener) {
Preconditions.checkNotNull(listener);
updateListeners.add(listener);
}
public void subscribeNodeClick(TreeMouseClickListener listener) {
Preconditions.checkNotNull(listener);
nodeClickListeners.add(listener);
}
public void subscribeNodeDoubleClick(TreeMouseClickListener listener) {
Preconditions.checkNotNull(listener);
nodeDoubleClickListeners.add(listener);
}
public void subscribeKeyEvent(TreeKeyEventListener listener) {
Preconditions.checkNotNull(listener);
keyEventListeners.add(listener);
}
private void setButtonMode(Canvas canvas, Tree<T> node, ExpandButtonInteractionListener listener) {
if (listener.isMouseOver()) {
canvas.setMode(node.isExpanded() ? CONTRACT_HOVER_MODE : EXPAND_HOVER_MODE);
} else {
canvas.setMode(node.isExpanded() ? CONTRACT_MODE : EXPAND_MODE);
}
}
private void setNodeMode(Canvas canvas, Tree<T> node, TreeViewListenerSet listenerSet) {
if (state.getSelectedIndex() != null
&& node.equals(model.get().getNode(state.getSelectedIndex()))) {
canvas.setMode(ACTIVE_MODE);
} else if (listenerSet.isMouseOver()) {
canvas.setMode(isEnabled() ? HOVER_MODE : HOVER_DISABLED_MODE);
} else if (!isEnabled()) {
canvas.setMode(DISABLED_MODE);
} else {
canvas.setMode(DEFAULT_MODE);
}
}
private void drawButton(Canvas canvas, Rect2i buttonRegion, ExpandButtonInteractionListener listener) {
canvas.drawBackground(buttonRegion);
canvas.addInteractionRegion(listener, buttonRegion);
}
private void drawNode(Canvas canvas, Rect2i nodeRegion, Tree<T> node, TreeViewListenerSet listenerSet) {
canvas.drawBackground(nodeRegion);
itemRenderer.draw(node.getValue(), canvas, canvas.getCurrentStyle().getMargin().shrink(nodeRegion));
// Add the top listener.
canvas.addInteractionRegion(listenerSet.getTopListener(), itemRenderer.getTooltip(node.getValue()),
Rect2i.createFromMinAndSize(nodeRegion.minX(), nodeRegion.minY(),
nodeRegion.width(), nodeRegion.height() / 3));
// Add the central listener.
canvas.addInteractionRegion(listenerSet.getCenterListener(), itemRenderer.getTooltip(node.getValue()),
Rect2i.createFromMinAndSize(nodeRegion.minX(), nodeRegion.minY() + nodeRegion.height() / 3,
nodeRegion.width(), nodeRegion.height() / 3));
int heightOffset = nodeRegion.height() - 3 * (nodeRegion.height() / 3);
// Add the bottom listener.
canvas.addInteractionRegion(listenerSet.getBottomListener(), itemRenderer.getTooltip(node.getValue()),
Rect2i.createFromMinAndSize(nodeRegion.minX(), nodeRegion.minY() + 2 * nodeRegion.height() / 3,
nodeRegion.width(), heightOffset + nodeRegion.height() / 3));
}
private void drawDragHint(Canvas canvas, Rect2i nodeRegion) {
if (state.getMouseOverType() == MouseOverType.TOP) {
// Draw a line at the top of the node.
canvas.drawLine(nodeRegion.minX(), nodeRegion.minY(), nodeRegion.maxX(), nodeRegion.minY(), Color.WHITE);
} else if (state.getMouseOverType() == MouseOverType.CENTER) {
// Draw a border around the node.
canvas.drawLine(nodeRegion.minX(), nodeRegion.minY(), nodeRegion.maxX(), nodeRegion.minY(), Color.WHITE);
canvas.drawLine(nodeRegion.maxX(), nodeRegion.minY(), nodeRegion.maxX(), nodeRegion.maxY(), Color.WHITE);
canvas.drawLine(nodeRegion.minX(), nodeRegion.minY(), nodeRegion.minX(), nodeRegion.maxY(), Color.WHITE);
canvas.drawLine(nodeRegion.minX(), nodeRegion.maxY(), nodeRegion.maxX(), nodeRegion.maxY(), Color.WHITE);
} else { // MouseOverType.BOTTOM
// Draw a line at the bottom of the node.
canvas.drawLine(nodeRegion.minX(), nodeRegion.maxY(), nodeRegion.maxX(), nodeRegion.maxY(), Color.WHITE);
}
}
private void updateListeners() {
boolean mouseOver = false;
for (TreeViewListenerSet set : treeViewListenerSets) {
if (set.isMouseOver()) {
mouseOver = true;
break;
}
}
if (!mouseOver) {
// Reset the temporary index variables.
if (state.getDraggedIndex() != null) {
state.setDraggedIndex(null);
}
if (state.getMouseOverIndex() != null) {
state.setMouseOverIndex(null);
state.setMouseOverType(null);
}
}
// Update the listener sets.
while (treeViewListenerSets.size() > model.get().getNodeCount()) {
treeViewListenerSets.remove(treeViewListenerSets.size() - 1);
expandListeners.remove(expandListeners.size() - 1);
}
while (treeViewListenerSets.size() < model.get().getNodeCount()) {
treeViewListenerSets.add(new TreeViewListenerSet(
new NodeTopListener(treeViewListenerSets.size()),
new NodeCenterListener(treeViewListenerSets.size()),
new NodeBottomListener(treeViewListenerSets.size())));
expandListeners.add(new ExpandButtonInteractionListener(expandListeners.size()));
}
}
private boolean moveSelected(int keyId) {
if (state.getSelectedIndex() != null) {
Tree<T> selectedNode = model.get().getNode(state.getSelectedIndex());
Tree<T> parent = selectedNode.getParent();
if (!selectedNode.isRoot()) {
int nodeIndex = parent.getIndex(selectedNode);
if (keyId == Keyboard.KeyId.UP && nodeIndex > 0) {
// Move the node up, unless it is the first node.
parent.removeChild(selectedNode);
parent.addChild(nodeIndex - 1, selectedNode);
model.get().resetNodes();
// Re-select the moved node.
state.setSelectedIndex(model.get().indexOf(selectedNode));
fireUpdateListeners();
} else if (keyId == Keyboard.KeyId.DOWN && nodeIndex < parent.getChildren().size() - 1) {
// Move the node down, unless it is the last node.
parent.removeChild(selectedNode);
parent.addChild(nodeIndex + 1, selectedNode);
model.get().resetNodes();
// Re-select the moved node.
state.setSelectedIndex(model.get().indexOf(selectedNode));
fireUpdateListeners();
}
}
return true;
}
return false;
}
private boolean removeSelected() {
if (state.getSelectedIndex() != null) {
model.get().removeNode(state.getSelectedIndex());
state.setSelectedIndex(null);
fireUpdateListeners();
return true;
}
return false;
}
private boolean onNodeClick(int index, NUIMouseClickEvent event) {
for (TreeMouseClickListener listener : nodeClickListeners) {
listener.onMouseClick(event, model.get().getNode(index));
}
if (isEnabled() && event.getMouseButton() == MouseInput.MOUSE_LEFT) {
// Select the node on LMB - deselect when selected again.
if (state.getSelectedIndex() != null && state.getSelectedIndex() == index) {
state.setSelectedIndex(null);
} else {
state.setSelectedIndex(index);
}
state.setAlternativeWidget(null);
return true;
}
return false;
}
private boolean onNodeDoubleClick(int index, NUIMouseDoubleClickEvent event) {
for (TreeMouseClickListener listener : nodeDoubleClickListeners) {
listener.onMouseClick(event, model.get().getNode(index));
}
return true;
}
private void onNodeMouseDrag(int index) {
state.setDraggedIndex(index);
}
private void onNodeMouseOver(int index, MouseOverType type) {
// Set temporary index variables for the dragged/target nodes.
if (state.getDraggedIndex() != null) {
if (state.getDraggedIndex() != index) {
state.setMouseOverIndex(index);
state.setMouseOverType(type);
} else {
state.setMouseOverIndex(null);
state.setMouseOverType(null);
}
}
}
private void onNodeMouseRelease(int index) {
if (state.getDraggedIndex() != null && state.getMouseOverIndex() != null) {
Tree<T> child = model.get().getNode(state.getDraggedIndex());
Tree<T> parent = model.get().getNode(state.getMouseOverIndex());
// Handle node drag&dropping.
if (state.getMouseOverType() == MouseOverType.TOP) {
// Insert the dragged node before the target node (as a child of the same tree).
child.getParent().removeChild(child);
parent.getParent().addChild(parent.getParent().indexOf(parent), child);
} else if (state.getMouseOverType() == MouseOverType.CENTER) {
// Insert the dragged node as a child of the target node.
child.getParent().removeChild(child);
parent.addChild(child);
} else { // MouseOverType.BOTTOM
// Insert the dragged node after the target node (as a child of the same tree).
child.getParent().removeChild(child);
parent.getParent().addChild(parent.getParent().indexOf(parent) + 1, child);
}
fireUpdateListeners();
}
// Reset the temporary index variables.
if (state.getDraggedIndex() != null) {
if (state.getMouseOverIndex() != null && state.getMouseOverIndex() != index) {
state.setSelectedIndex(null);
}
state.setDraggedIndex(null);
}
if (state.getMouseOverIndex() != null) {
state.setMouseOverIndex(null);
state.setMouseOverType(null);
}
}
private class ExpandButtonInteractionListener extends BaseInteractionListener {
private int index;
ExpandButtonInteractionListener(int index) {
this.index = index;
}
@Override
public boolean onMouseClick(NUIMouseClickEvent event) {
if (event.getMouseButton() == MouseInput.MOUSE_LEFT) {
// Expand or contract a node on LMB - works even if the tree is disabled.
model.get().getNode(index).setExpanded(!model.get().getNode(index).isExpanded());
Tree<T> selectedNode = state.getSelectedIndex() != null ? model.get().getNode(state.getSelectedIndex()) : null;
model.get().resetNodes();
// Update the index of the selected node.
if (selectedNode != null) {
int newIndex = model.get().indexOf(selectedNode);
if (newIndex == -1) {
state.setSelectedIndex(null);
} else {
state.setSelectedIndex(newIndex);
}
}
return true;
}
return false;
}
}
private class NodeTopListener extends BaseInteractionListener {
private int index;
NodeTopListener(int index) {
this.index = index;
}
@Override
public boolean onMouseClick(NUIMouseClickEvent event) {
return onNodeClick(index, event);
}
@Override
public boolean onMouseDoubleClick(NUIMouseDoubleClickEvent event) {
return onNodeDoubleClick(index, event);
}
@Override
public void onMouseDrag(NUIMouseDragEvent event) {
onNodeMouseDrag(index);
}
@Override
public void onMouseOver(NUIMouseOverEvent event) {
super.onMouseOver(event);
// This node's parent exists and accepts the node being dragged as a child.
if (state.getDraggedIndex() != null
&& !model.get().getNode(index).isRoot()
&& model.get().getNode(index).getParent().acceptsChild(model.get().getNode(state.getDraggedIndex()))) {
onNodeMouseOver(index, MouseOverType.TOP);
}
}
@Override
public void onMouseRelease(NUIMouseReleaseEvent event) {
onNodeMouseRelease(index);
}
}
private class NodeCenterListener extends BaseInteractionListener {
private int index;
NodeCenterListener(int index) {
this.index = index;
}
@Override
public boolean onMouseClick(NUIMouseClickEvent event) {
return onNodeClick(index, event);
}
@Override
public boolean onMouseDoubleClick(NUIMouseDoubleClickEvent event) {
return onNodeDoubleClick(index, event);
}
@Override
public void onMouseDrag(NUIMouseDragEvent event) {
onNodeMouseDrag(index);
}
@Override
public void onMouseOver(NUIMouseOverEvent event) {
super.onMouseOver(event);
// This node accepts the node being dragged as a child.
if (state.getDraggedIndex() != null
&& model.get().getNode(index).acceptsChild(model.get().getNode(state.getDraggedIndex()))) {
onNodeMouseOver(index, MouseOverType.CENTER);
}
}
@Override
public void onMouseRelease(NUIMouseReleaseEvent event) {
onNodeMouseRelease(index);
}
}
private class NodeBottomListener extends BaseInteractionListener {
private int index;
NodeBottomListener(int index) {
this.index = index;
}
@Override
public boolean onMouseClick(NUIMouseClickEvent event) {
return onNodeClick(index, event);
}
@Override
public boolean onMouseDoubleClick(NUIMouseDoubleClickEvent event) {
return onNodeDoubleClick(index, event);
}
@Override
public void onMouseDrag(NUIMouseDragEvent event) {
onNodeMouseDrag(index);
}
@Override
public void onMouseOver(NUIMouseOverEvent event) {
super.onMouseOver(event);
// This node's parent exists and accepts the node being dragged as a child.
if (state.getDraggedIndex() != null
&& !model.get().getNode(index).isRoot()
&& model.get().getNode(index).getParent().acceptsChild(model.get().getNode(state.getDraggedIndex()))) {
onNodeMouseOver(index, MouseOverType.BOTTOM);
}
}
@Override
public void onMouseRelease(NUIMouseReleaseEvent event) {
onNodeMouseRelease(index);
}
}
/**
* A set of tree node sub-listeners.
*/
private final class TreeViewListenerSet {
/**
* The top listener.
*/
private NodeTopListener topListener;
/**
* The central listener.
*/
private NodeCenterListener centerListener;
/**
* The bottom listener.
*/
private NodeBottomListener bottomListener;
private TreeViewListenerSet(NodeTopListener topListener, NodeCenterListener centerListener, NodeBottomListener bottomListener) {
this.topListener = topListener;
this.centerListener = centerListener;
this.bottomListener = bottomListener;
}
/**
* @return The top listener.
*/
NodeTopListener getTopListener() {
return topListener;
}
/**
* @return The central listener.
*/
NodeCenterListener getCenterListener() {
return centerListener;
}
/**
* @return The bottom listener.
*/
NodeBottomListener getBottomListener() {
return bottomListener;
}
/**
* @return Whether any of the listeners are currently moused over.
*/
public boolean isMouseOver() {
return topListener.isMouseOver() || centerListener.isMouseOver() || bottomListener.isMouseOver();
}
}
}