// Copyright 2012 Google Inc. All Rights Reserved.
//
// 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 com.google.collide.client.ui.tree;
import com.google.collide.client.ui.tree.Tree.Css;
import com.google.collide.client.util.logging.Log;
import com.google.collide.json.client.JsoArray;
import com.google.collide.json.shared.JsonArray;
import org.waveprotocol.wave.client.common.util.SignalEvent;
/*
* TODO : Since we have breadcrumbs and will soon have tabs, we don't
* need the notion of an active node that is separate from selected nodes.
*/
/**
* Selection model for selected selected nodes in a {@link Tree}.
*
* A Tree allows for multiple selected nodes with modifiers for ctrl and shift
* click driven selects.
*/
public class SelectionModel<D> {
private JsoArray<D> selectedNodes;
private final NodeDataAdapter<D> dataAdapter;
private final Css css;
public SelectionModel(NodeDataAdapter<D> dataAdapter, Tree.Css css) {
this.dataAdapter = dataAdapter;
this.css = css;
this.selectedNodes = JsoArray.create();
}
/**
* Adds the node to the selection if it isn't already there in response to a
* context selection.
*/
public boolean contextSelect(D nodeData) {
if (selectedNodes.isEmpty()) {
// There are no selected nodes. So we should select.
insertAndSelectNode(nodeData, 0, true);
return true;
}
if (!hasSameParent(selectedNodes.get(0), nodeData) || selectedNodes.size() == 1) {
return selectSingleNode(nodeData);
}
if (!selectedNodes.contains(nodeData)) {
int insertionIndex = getInsertionIndex(nodeData);
insertAndSelectNode(nodeData, insertionIndex, true);
return true;
}
return false;
}
/**
* Returns the list of selected nodes. Not a copy. So don't play fast and
* loose mutating the list outside of this API!
*/
public JsoArray<D> getSelectedNodes() {
return selectedNodes;
}
public void removeNode(D nodeData) {
selectedNodes.remove(nodeData);
}
/**
* Restores visual selection for all selected nodes tracked by the
* SelectionModel.
*/
public JsonArray<JsonArray<String>> computeSelectedPaths() {
JsoArray<JsonArray<String>> selectedPaths = JsoArray.create();
for (int i = 0, n = selectedNodes.size(); i < n; i++) {
D nodeData = selectedNodes.get(i);
selectedPaths.add(dataAdapter.getNodePath(nodeData));
}
return selectedPaths;
}
/**
* Adds the specified node to the list of selected nodes. The list of selected
* nodes is guaranteed to be sorted in the same direction that the nodes
* appear in the tree. We only allow nodes to be in the selected list that are
* peers in the tree (we do not allow selects to span multiple depths in the
* tree.
*
* Behavior: If no modifier key is depressed, the list of selected nodes will
* be set to contain just the node passed to this method.
*
* Shift select: If shift is depressed, then we attempt to do a continuous
* range select. If there exists one or more nodes in the selected nodes list,
* we test if the node falls within the list. If it does not fall within, we
* connect the contiguous range of nodes from the specified node to the
* nearest selected node. If the node falls within the list, we do a
* continuous range selection to the LAST node that was selected, not the
* closest.
*
* CTRL select: If CTRL is depressed then we simply search for the insertion
* point of the specified node in the already sorted select list. If the node
* is already present, then we remove it and unselect the node. If it was not
* present, then we insert the node at the appropriate spot in the array and
* select it.
*
* @param nodeData the node to select
* @param event the DOM event that was associated with the select trigger.
* This is needed to detect modifier keys. If {@code null} then we
* assume that we are appending to the selection and behave just like a
* CTRL-click.
* @return whether or not the select region changed at all.
*/
public boolean selectNode(D nodeData, SignalEvent event) {
if (selectedNodes.isEmpty()) {
// There are no selected nodes. So we should select.
insertAndSelectNode(nodeData, 0, true);
return true;
}
// Ensure that the node we are selecting is a child of the same
// directory of the other nodes.
if (!hasSameParent(selectedNodes.get(0), nodeData)) {
return selectSingleNode(nodeData);
}
// So we are guaranteed to have a node that is a peer of the current set of
// nodes. Now we must examine modifier keys.
if (event == null || event.getCommandKey()) {
ctrlSelect(nodeData);
return true;
} else {
if (event.getShiftKey()) {
return shiftSelect(nodeData);
}
}
// Neither a shift nor a ctrl select. So replace the contents of the
// selected list with this node.
return selectSingleNode(nodeData);
}
/**
* Clears the the current selection and selects a single node.
*
* @return returns whether or not we actually changed the selection.
*/
public boolean selectSingleNode(D nodeData) {
// This is the case where we have a single node selected, and it is the same
// one we are clicking. We do nothing in this case.
if ((selectedNodes.size() == 1) && (selectedNodes.get(0).equals(nodeData))) {
return false;
}
clearSelections();
insertAndSelectNode(nodeData, 0, true);
return true;
}
public void clearSelections() {
visuallySelect(selectedNodes, false);
selectedNodes.clear();
}
/**
* Collects all nodes in a continuous range from the start node to the end
* node (assuming that the nodes are peers) obeying the inclusion boolean
* params for the boundaries. These nodes are not allowed to be in the
* selected list.
*/
private JsoArray<D> collectRangeToSelect(
D startNode, D endNode, boolean includeStart, boolean includeEnd) {
D parentNode = dataAdapter.getParent(startNode);
// Do some debug compile sanity checking.
assert (parentNode != null) : "Null parent nmode when doing range select!";
assert (parentNode.equals(
dataAdapter.getParent(endNode))) : "Different parent nodes when doing range highlgiht!";
assert (dataAdapter.compare(startNode, endNode) <= 0) :
"Nodes are in reverse order for range select! " + dataAdapter.getNodeName(startNode) + " - "
+ dataAdapter.getNodeName(endNode);
JsoArray<D> range = JsoArray.create();
// Do a linear scan until we find the startNode.
JsonArray<D> children = dataAdapter.getChildren(parentNode);
int i = 0;
boolean adding = false;
for (int n = children.size(); i < n; i++) {
D child = children.get(i);
if (child.equals(startNode)) {
adding = true;
if (includeStart) {
range.add(child);
}
continue;
}
if (adding) {
if (child.equals(endNode)) {
if (!includeEnd) {
break;
}
range.add(child);
break;
}
range.add(child);
}
}
// Sanity check
if (i == children.size()) {
Log.error(getClass(), "Failed to find the start when doing a range selection. Start:",
startNode, " End:", endNode);
}
return range;
}
/**
* If CTRL is depressed then we simply search for the insertion point of the
* specified node in the already sorted select list. If the node is already
* present, then we remove it and unselect the node. If it was not present,
* then we insert the node at the appropriate spot in the array and select it.
*/
private void ctrlSelect(D nodeData) {
// Find the relevant spot in the list of selected nodes.
int insertionIndex = getInsertionIndex(nodeData);
// Either select or not select depending on whether or not it was
// already present in the list.
insertAndSelectNode(
nodeData, insertionIndex, !nodeData.equals(selectedNodes.get(insertionIndex)));
}
private int getInsertionIndex(D nodeData) {
int insertionIndex = 0;
while (insertionIndex < selectedNodes.size()
&& dataAdapter.compare(nodeData, selectedNodes.get(insertionIndex)) > 0) {
insertionIndex++;
}
return insertionIndex;
}
private boolean hasSameParent(D a, D b) {
D parent1 = dataAdapter.getParent(a);
D parent2 = dataAdapter.getParent(b);
return parent1 == parent2 || (parent1 != null && parent1.equals(parent2));
}
private void insertAndSelectNode(D nodeData, int insertionIndex, boolean selectingNewNode) {
// Visually represent it.
visuallySelect(nodeData, selectingNewNode);
// Update the model.
if (selectingNewNode) {
// The node was not in the list. Add it.
selectedNodes.splice(insertionIndex, 0, nodeData);
} else {
// The node was already in the list. Take it out.
selectedNodes.splice(insertionIndex, 1);
}
}
/**
* If shift is depressed, then we attempt to do a continuous range select. If
* there exists one or more nodes in the selected nodes list, we test if the
* node falls within the list. If it does not fall within, we connect the
* contiguous range of nodes from the specified node to the nearest selected
* node. If the node falls within the list, we do a continuous range selection
* to the LAST node that was selected, not the closest.
*/
private boolean shiftSelect(D nodeData) {
// We are guaranteed to have at least one node in the list.
D firstNode = selectedNodes.get(0);
D lastNode = selectedNodes.get(selectedNodes.size() - 1);
int comparisonToFirst = dataAdapter.compare(nodeData, firstNode);
int comparisonToLast = dataAdapter.compare(nodeData, lastNode);
// If it is to the left.
if (comparisonToFirst < 0) {
JsoArray<D> range = collectRangeToSelect(nodeData, firstNode, true, false);
visuallySelect(range, true);
selectedNodes = JsoArray.concat(range, selectedNodes);
return true;
}
// If it is to the right.
if (comparisonToLast > 0) {
JsoArray<D> range = collectRangeToSelect(lastNode, nodeData, false, true);
visuallySelect(range, true);
selectedNodes = JsoArray.concat(selectedNodes, range);
return true;
}
// If it is somewhere in between, or on the boundary.
if (comparisonToFirst >= 0 && comparisonToLast <= 0) {
// Clear the set of selected nodes.
clearSelections();
selectedNodes = collectRangeToSelect(nodeData, lastNode, true, true);
visuallySelect(selectedNodes, true);
return true;
}
assert false : "SelectionModel#shiftSelect(D): This should be unreachable!";
return false;
}
private void visuallySelect(D nodeData, boolean isSelected) {
TreeNodeElement<D> renderedNode = dataAdapter.getRenderedTreeNode(nodeData);
if (renderedNode != null) {
renderedNode.setSelected(isSelected, css);
}
}
private void visuallySelect(JsonArray<D> nodeDatas, boolean isSelected) {
for (int i = 0, n = nodeDatas.size(); i < n; i++) {
visuallySelect(nodeDatas.get(i), isSelected);
}
}
}