// 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.shared.document.anchor;
import com.google.collide.json.shared.JsonArray;
import com.google.collide.shared.document.Line;
import com.google.collide.shared.document.anchor.InsertionPlacementStrategy.Placement;
import com.google.collide.shared.util.JsonCollections;
import com.google.collide.shared.util.SortedList;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
// TODO: need to make an interface for the truly public methods
/**
* Manager for anchors within a document.
*
*/
public class AnchorManager {
/**
* Visitor that is called for each anchor in some collection of anchors.
*/
public interface AnchorVisitor {
void visitAnchor(Anchor anchor);
}
/*
* Much logic relies on the fact that in a line's anchor list, these will
* appear before anchors that care about column numbers.
*/
/**
* Constant value for the line number to indicate that the anchor does not
* care about its line number.
*/
public static final int IGNORE_LINE_NUMBER = -1;
/**
* Constant value for the column to indicate that the anchor does not have a
* column.
*
* This is useful for anchors that only care about knowing movements of
* lines, and not specific columns. For example, a viewport may use this for
* the viewport top and viewport bottom.
*/
public static final int IGNORE_COLUMN = -1;
private static final String LINE_TAG_ANCHORS = AnchorManager.class.getName() + ":Anchors";
private final LineAnchorList lineAnchors;
// TODO: not really public
public AnchorManager() {
lineAnchors = new LineAnchorList();
}
/**
* @param anchorType the type of the anchor
* @param line the line the anchor should attach to
* @param lineNumber the line number, or {@link #IGNORE_LINE_NUMBER} if this
* anchor is not interested in its line number
* @param column the column, or {@link #IGNORE_COLUMN} if this anchor is not
* positioned on a column
*/
public Anchor createAnchor(AnchorType anchorType, Line line, int lineNumber, int column) {
Anchor anchor = new Anchor(anchorType, line, lineNumber, column);
getAnchors(line).add(anchor);
if (lineNumber != IGNORE_LINE_NUMBER) {
lineAnchors.add(anchor);
}
return anchor;
}
/**
* Searches the given {@code line} for an anchor with a line number, or returns null.
*/
public Anchor findAnchorWithLineNumber(Line line) {
AnchorList anchors = getAnchorsOrNull(line);
if (anchors == null) {
return null;
}
for (int i = 0, n = anchors.size(); i < n; i++) {
if (anchors.get(i).hasLineNumber()) {
return anchors.get(i);
}
}
return null;
}
/**
* Finds the closest anchor with a line number, or null. The anchor may be
* positioned on another line.
*/
public Anchor findClosestAnchorWithLineNumber(int lineNumber) {
if (lineAnchors.size() == 0) {
return null;
}
int insertionIndex = lineAnchors.findInsertionIndex(lineNumber);
if (insertionIndex == lineAnchors.size()) {
return lineAnchors.get(insertionIndex - 1);
} else if (insertionIndex == 0) {
return lineAnchors.get(0);
}
int distanceFromPreviousAnchor =
lineNumber - lineAnchors.get(insertionIndex - 1).getLineNumber();
int distanceFromNextAnchor = lineAnchors.get(insertionIndex).getLineNumber() - lineNumber;
return distanceFromNextAnchor < distanceFromPreviousAnchor
? lineAnchors.get(insertionIndex) : lineAnchors.get(insertionIndex - 1);
}
/**
* Returns the list of anchors on the given line. The returned instance is the
* original list, not a copy.
*
* @see #getAnchorsOrNull(Line)
*/
@VisibleForTesting
public static AnchorList getAnchors(Line line) {
AnchorList columnAnchors = line.getTag(LINE_TAG_ANCHORS);
if (columnAnchors == null) {
columnAnchors = new AnchorList();
line.putTag(LINE_TAG_ANCHORS, columnAnchors);
}
return columnAnchors;
}
/**
* Returns the list of anchors on the given line (the returned instance is the
* original list, not a copy), or null if there are no anchors
*/
static AnchorList getAnchorsOrNull(Line line) {
return line.getTag(LINE_TAG_ANCHORS);
}
/**
* Returns anchors of the given type on the given line, or null if there are
* no anchors of any kind on the given line.
*/
public static JsonArray<Anchor> getAnchorsByTypeOrNull(Line line, AnchorType type) {
AnchorList anchorList = getAnchorsOrNull(line);
if (anchorList == null) {
return null;
}
JsonArray<Anchor> anchors = JsonCollections.createArray();
for (int i = 0; i < anchorList.size(); i++) {
Anchor anchor = anchorList.get(i);
if (type.equals(anchor.getType())) {
anchors.add(anchor);
}
}
return anchors;
}
@VisibleForTesting
public LineAnchorList getLineAnchors() {
return lineAnchors;
}
/**
* Finds the next anchor relative to the one given or null if there are no
* subsequent anchors.
*/
public Anchor getNextAnchor(Anchor anchor) {
return getAdjacentAnchor(anchor, null, true);
}
/**
* Finds the next anchor of the given type relative to the one given or null
* if there are no subsequent anchors of that type.
*/
public Anchor getNextAnchor(Anchor anchor, AnchorType type) {
return getAdjacentAnchor(anchor, type, true);
}
/**
* Finds the previous anchor relative to the one given or null if there are no
* preceding anchors.
*/
public Anchor getPreviousAnchor(Anchor anchor) {
return getAdjacentAnchor(anchor, null, false);
}
/**
* Finds the previous anchor of the given type relative to the one given or
* null if there are no preceding anchors of that type.
*/
public Anchor getPreviousAnchor(Anchor anchor, AnchorType type) {
return getAdjacentAnchor(anchor, type, false);
}
/**
* Private utility method that performs the search for getNextAnchor/getPreviousAnchor
*
* For now, the performance is O(lines + anchors)
*
* TODO: Instead we should consider maintaining a separate anchor.
* TODO: avoid recusrion list. This should let us easily achieve O(anchors)
* or better.
*
* @param anchor the @{link Anchor} anchor to start the search from
* @param type the @{link AnchorType} of the anchor, or null to capture any type.
* @param next true if the search is forwards, false for backwards
* @return the adjacent anchor, or null if no such anchor is found
*/
private Anchor getAdjacentAnchor(Anchor anchor, AnchorType type, boolean next) {
Line currentLine = anchor.getLine();
AnchorList list = getAnchors(currentLine);
// Special case for the same line
int insertionIndex = list.findInsertionIndex(anchor);
int lowerBound = next ? 0 : 1;
int upperBound = next ? list.size() - 2 : list.size() - 1;
if (insertionIndex >= lowerBound && insertionIndex <= upperBound) {
Anchor anchorInList = list.get(insertionIndex);
if (anchor == anchorInList) {
// We found the anchor in the list, and we have a neighbor to return
Anchor candidateAnchor;
if (next) {
candidateAnchor = list.get(insertionIndex + 1);
} else {
candidateAnchor = list.get(insertionIndex - 1);
}
// Enforce the type
if (type != null && !candidateAnchor.getType().equals(type)) {
return getAdjacentAnchor(candidateAnchor, type, next);
} else {
return candidateAnchor;
}
}
// Otherwise, the anchor must be on another line
}
currentLine = next ? currentLine.getNextLine() : currentLine.getPreviousLine();
while (currentLine != null) {
list = getAnchorsOrNull(currentLine);
if (list != null && list.size() > 0) {
Anchor candidateAnchor;
if (next) {
candidateAnchor = list.get(0);
} else {
candidateAnchor = list.get(list.size() - 1);
}
// Enforce the type
if (type != null && !candidateAnchor.getType().equals(type)) {
return getAdjacentAnchor(candidateAnchor, type, next);
} else {
return candidateAnchor;
}
}
currentLine = next ? currentLine.getNextLine() : currentLine.getPreviousLine();
}
return null;
}
// TODO: not public
public void handleTextPredeletionForLine(Line line, int column, int deleteCountForLine,
final JsonArray<Anchor> anchorsInDeletedRangeToRemove,
final JsonArray<Anchor> anchorsInDeletedRangeToShift, boolean isFirstLine) {
AnchorList anchors = getAnchorsOrNull(line);
if (anchors == null) {
return;
}
boolean entireLineDeleted = line.getText().length() == deleteCountForLine;
assert !entireLineDeleted || column == 0;
if (entireLineDeleted && !isFirstLine) {
// If entire line is deleted, shift/remove line anchors too
for (int i = 0, n = anchors.size(); i < n && anchors.get(i).isLineAnchor(); i++) {
categorizeAccordingToRemovalStrategy(anchors.get(i), anchorsInDeletedRangeToRemove,
anchorsInDeletedRangeToShift);
}
}
/*
* To support the cursor at the end of a line (without a newline), we must
* extend past the last column of the line
*/
int lastColumnToDelete =
entireLineDeleted ? Integer.MAX_VALUE : column + deleteCountForLine - 1;
for (int i = anchors.findInsertionIndex(column, Anchor.ID_FIRST_IN_COLUMN), n = anchors.size();
i < n && anchors.get(i).getColumn() <= lastColumnToDelete; i++) {
categorizeAccordingToRemovalStrategy(anchors.get(i), anchorsInDeletedRangeToRemove,
anchorsInDeletedRangeToShift);
}
}
// TODO: not public
public void handleTextDeletionLastLineLeftover(JsonArray<Anchor> anchorsLeftoverFromLastLine,
Line firstLine, Line lastLine, int lastLineFirstUntouchedColumn) {
AnchorList anchors = getAnchorsOrNull(lastLine);
if (anchors == null) {
return;
}
if (firstLine != lastLine) {
for (int i = 0, n = anchors.size(); i < n && anchors.get(i).isLineAnchor(); i++) {
anchorsLeftoverFromLastLine.add(anchors.get(i));
}
}
for (int i =
anchors.findInsertionIndex(lastLineFirstUntouchedColumn, Anchor.ID_FIRST_IN_COLUMN), n =
anchors.size(); i < n; i++) {
anchorsLeftoverFromLastLine.add(anchors.get(i));
}
}
// TODO: not public
/**
* @param lastLineFirstUntouchedColumn the left-most column that was not part
* of the deletion. If all of the characters on the last line were
* deleted, this will be the length of the text on the line
*/
public void handleTextDeletionFinished(JsonArray<Anchor> anchorsInDeletedRangeToRemove,
JsonArray<Anchor> anchorsInDeletionRangeToShift, JsonArray<Anchor> anchorsLeftoverOnLastLine,
Line firstLine, int firstLineNumber, int firstLineColumn, int numberOfLinesDeleted,
int lastLineFirstUntouchedColumn) {
AnchorDeferredDispatcher dispatcher = new AnchorDeferredDispatcher();
// Remove anchors that did not want to be shifted
for (int i = 0, n = anchorsInDeletedRangeToRemove.size(); i < n; i++) {
removeAnchorDeferDispatch(anchorsInDeletedRangeToRemove.get(i), dispatcher);
}
// Shift anchors that were part of the deletion range and want to be shifted
for (int i = 0, n = anchorsInDeletionRangeToShift.size(); i < n; i++) {
Anchor anchor = anchorsInDeletionRangeToShift.get(i);
updateAnchorPositionObeyingExistingIgnoresWithoutDispatch(anchor,
firstLine, firstLineNumber, firstLineColumn);
dispatcher.deferDispatchShifted(anchor);
}
/*
* Shift anchors that were on the leftover text on the last line (now their
* text lives on the first line)
*/
for (int i = 0, n = anchorsLeftoverOnLastLine.size(); i < n; i++) {
Anchor anchor = anchorsLeftoverOnLastLine.get(i);
int anchorFirstLineColumn =
anchor.getColumn() - lastLineFirstUntouchedColumn + firstLineColumn;
updateAnchorPositionObeyingExistingIgnoresWithoutDispatch(anchor, firstLine, firstLineNumber,
anchorFirstLineColumn);
dispatcher.deferDispatchShifted(anchor);
}
if (numberOfLinesDeleted > 0) {
/*
* Shift the line numbers of anchors past the deleted range (note that the
* anchors still have their old line numbers, hence the
* "+ numberOfLinesDeleted")
*/
shiftLineNumbersDeferDispatch(firstLineNumber + numberOfLinesDeleted + 1,
-numberOfLinesDeleted, dispatcher);
}
dispatcher.dispatch();
}
private void categorizeAccordingToRemovalStrategy(Anchor anchor,
JsonArray<Anchor> anchorsToRemove, JsonArray<Anchor> anchorsToShift) {
switch (anchor.getRemovalStrategy()) {
case SHIFT:
anchorsToShift.add(anchor);
break;
case REMOVE:
anchorsToRemove.add(anchor);
break;
}
}
// TODO: not public
public void handleMultilineTextInsertion(Line oldLine, int oldLineNumber, int oldColumn,
Line newLine, int newLineNumber, int newColumn) {
AnchorDeferredDispatcher dispatcher = new AnchorDeferredDispatcher();
/*
* Shift all of the following line anchors (remember that the anchor data
* structures do not know about the newly inserted lines yet, so
* oldLineNumber + 1 is the first line after the insertion point.
*/
shiftLineNumbersDeferDispatch(oldLineNumber + 1, newLineNumber - oldLineNumber,
dispatcher);
/*
* Now update the anchors on the line receiving the multiline text
* insertion
*/
AnchorList anchors = getAnchorsOrNull(oldLine);
if (anchors != null) {
// Shift *line* anchors (those without columns) if their strategies allow
for (int i = 0; i < anchors.size() && anchors.get(i).isLineAnchor();) {
Anchor anchor = anchors.get(i);
if (isInsertionPlacementStrategyLater(anchor, oldLine, oldColumn)) {
updateAnchorPositionWithoutDispatch(anchor, newLine, newLineNumber,
AnchorManager.IGNORE_COLUMN);
dispatcher.deferDispatchShifted(anchor);
} else {
i++;
}
}
/*
* Consider moving the anchors that are positioned greater than or equal
* to the column receiving the newline
*/
for (int i = anchors.findInsertionIndex(oldColumn, Anchor.ID_FIRST_IN_COLUMN); i < anchors
.size();) {
Anchor anchor = anchors.get(i);
if (anchor.getColumn() == oldColumn
&& !isInsertionPlacementStrategyLater(anchor, oldLine, oldColumn)) {
/*
* This anchor is on the same column as the split but does not want to
* be moved
*/
i++;
continue;
}
int newAnchorColumn = anchor.getColumn() - oldColumn + newColumn;
updateAnchorPositionObeyingExistingIgnoresWithoutDispatch(anchor, newLine, newLineNumber,
newAnchorColumn);
dispatcher.deferDispatchShifted(anchor);
// No need to touch i since the size of anchors is one smaller now
}
}
dispatcher.dispatch();
}
private boolean isInsertionPlacementStrategyLater(Anchor anchor, Line line, int column) {
InsertionPlacementStrategy.Placement placement =
anchor.getInsertionPlacementStrategy().determineForInsertion(anchor, line, column);
return placement == Placement.LATER;
}
// TODO: not public
public void handleSingleLineTextInsertion(Line line, int column, int length) {
shiftColumnAnchors(line, column, length, true);
}
/**
* Moves the anchor to a new position.
*/
public void moveAnchor(final Anchor anchor, Line line, int lineNumber, int column) {
updateAnchorPositionWithoutDispatch(anchor, line, lineNumber, column);
anchor.dispatchMoved();
}
private void updateAnchorPositionObeyingExistingIgnoresWithoutDispatch(Anchor anchor,
Line newLine, int newLineNumber, int newColumn) {
int anchorsNewColumn = anchor.getColumn() == IGNORE_COLUMN ? IGNORE_COLUMN : newColumn;
int anchorsNewLineNumber =
anchor.getLineNumber() == IGNORE_LINE_NUMBER ? IGNORE_LINE_NUMBER : newLineNumber;
updateAnchorPositionWithoutDispatch(anchor, newLine, anchorsNewLineNumber, anchorsNewColumn);
}
/**
* Moves the anchor without dispatching. This will update the anchor lists.
*/
private void updateAnchorPositionWithoutDispatch(final Anchor anchor, Line line, int lineNumber,
int column) {
Preconditions.checkState(anchor.isAttached(), "Cannot move detached anchor");
// Ensure it's different
if (anchor.getLine() == line && anchor.getLineNumber() == lineNumber
&& anchor.getColumn() == column) {
return;
}
// Remove the anchor
Line oldLine = anchor.getLine();
AnchorList oldAnchors = getAnchorsOrNull(oldLine);
if (oldAnchors == null) {
throw new IllegalStateException("List of line's anchors should not be null\nLine anchors:\n"
+ dumpAnchors(lineAnchors));
}
boolean removed = oldAnchors.remove(anchor);
if (!removed) {
throw new IllegalStateException(
"Could not find anchor in list of line's anchors\nAnchors on line:\n"
+ dumpAnchors(oldAnchors) + "\nLine anchors:\n" + dumpAnchors(lineAnchors));
}
if (anchor.hasLineNumber()) {
removed = lineAnchors.remove(anchor);
if (!removed) {
throw new IllegalStateException(
"Could not find anchor in list of anchors that care about line numbers\nLine anchors:\n"
+ dumpAnchors(lineAnchors) + "\nAnchors on line:\n" + dumpAnchors(oldAnchors));
}
}
// Update its position
anchor.setLineWithoutDispatch(line, lineNumber);
anchor.setColumnWithoutDispatch(column);
// Add it again so its in the list reflects its new position
AnchorList anchors = line.equals(oldLine) ? oldAnchors : getAnchors(line);
anchors.add(anchor);
if (lineNumber != IGNORE_LINE_NUMBER) {
lineAnchors.add(anchor);
}
}
public void removeAnchor(Anchor anchor) {
// Do not add any extra logic here
AnchorDeferredDispatcher dispatcher = new AnchorDeferredDispatcher();
removeAnchorDeferDispatch(anchor, dispatcher);
dispatcher.dispatch();
}
/**
* Clears the line anchors list. This is not public API.
*/
public void clearLineAnchors() {
lineAnchors.clear();
}
private void removeAnchorDeferDispatch(Anchor anchor, AnchorDeferredDispatcher dispatcher) {
if (anchor.hasLineNumber()) {
lineAnchors.remove(anchor);
}
AnchorList anchors = getAnchorsOrNull(anchor.getLine());
if (anchors != null) {
anchors.remove(anchor);
}
anchor.detach();
dispatcher.deferDispatchRemoved(anchor);
}
/**
* Shifts the column anchors anchored to the column or later by the given
* {@code shiftAmount}.
*
* @param consultPlacementStrategy true to consult and obey the placement
* strategies of anchors that lie exactly on {@code column}
*/
private void shiftColumnAnchors(Line line, int column, int shiftAmount,
boolean consultPlacementStrategy) {
final AnchorList anchors = getAnchorsOrNull(line);
if (anchors == null) {
return;
}
AnchorDeferredDispatcher dispatcher = new AnchorDeferredDispatcher();
int insertionIndex = anchors.findInsertionIndex(column, Anchor.ID_FIRST_IN_COLUMN);
for (int i = insertionIndex; i < anchors.size();) {
Anchor anchor = anchors.get(i);
boolean repositionAnchor = true;
if (consultPlacementStrategy && anchor.getColumn() == column) {
repositionAnchor = isInsertionPlacementStrategyLater(anchor, line, column);
}
if (repositionAnchor) {
anchors.remove(anchor);
anchor.setColumnWithoutDispatch(anchor.getColumn() + shiftAmount);
dispatcher.deferDispatchShifted(anchor);
// No need to increase i since we removed above
} else {
i++;
}
}
// Re-adding in the loop above can cause problems if shiftAmount > 0
JsonArray<Anchor> shiftedAnchors = dispatcher.getShiftedAnchors();
if (shiftedAnchors != null) {
for (int i = 0, n = shiftedAnchors.size(); i < n; i++) {
anchors.add(shiftedAnchors.get(i));
}
}
// Dispatch after all anchors are in their final, consistent state
dispatcher.dispatch();
}
/**
* Takes care of shifting the line numbers of interested anchors.
*
* @param lineNumber inclusive
*/
private void shiftLineNumbersDeferDispatch(final int lineNumber, int shiftAmount,
AnchorDeferredDispatcher dispatcher) {
int insertionIndex = lineAnchors.findInsertionIndex(lineNumber);
for (int i = insertionIndex, n = lineAnchors.size(); i < n; i++) {
Anchor anchor = lineAnchors.get(i);
anchor.setLineWithoutDispatch(anchor.getLine(), anchor.getLineNumber() + shiftAmount);
dispatcher.deferDispatchShifted(anchor);
}
}
private static String dumpAnchors(SortedList<Anchor> anchorList) {
StringBuilder sb = new StringBuilder();
for (int i = 0, n = anchorList.size(); i < n; i++) {
sb.append(anchorList.get(i).toString()).append("\n");
}
return sb.toString();
}
}