/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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.waveprotocol.wave.client.editor.content;
import org.waveprotocol.wave.client.editor.Editor;
import org.waveprotocol.wave.client.scheduler.Scheduler;
import org.waveprotocol.wave.client.scheduler.TimerService;
import org.waveprotocol.wave.model.document.AnnotationCursor;
import org.waveprotocol.wave.model.document.MutableAnnotationSet;
import org.waveprotocol.wave.model.document.indexed.LocationMapper;
import org.waveprotocol.wave.model.document.raw.TextNodeOrganiser;
import org.waveprotocol.wave.model.document.util.Annotations;
import org.waveprotocol.wave.model.document.util.DocHelper;
import org.waveprotocol.wave.model.document.util.DocumentContext;
import org.waveprotocol.wave.model.document.util.ElementManager;
import org.waveprotocol.wave.model.document.util.LocalDocument;
import org.waveprotocol.wave.model.document.util.PersistentContent;
import org.waveprotocol.wave.model.document.util.Point;
import org.waveprotocol.wave.model.document.util.Property;
import org.waveprotocol.wave.model.document.util.ReadableDocumentView;
import org.waveprotocol.wave.model.util.ConcurrentSet;
import org.waveprotocol.wave.model.util.ReadableStringSet;
import org.waveprotocol.wave.model.util.ReadableStringSet.Proc;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
/**
* A class for painting annotations that need simple stylistic renderings.
*
* @author danilatos@google.com (Daniel Danilatos)
*/
public class AnnotationPainter {
/**
* Property on the ContentDocument to indicate isEditing state;
*
* If non-null the editor is in editing mode, else it in non-editing mode.
*/
public static final Property<Boolean> DOCUMENT_MODE =
Property.immutable("doc_mode");
public static <N, E extends N, T extends N> boolean isEditing(LocalDocument<N, E, T> doc) {
return isInEditingDocument(doc, doc.getDocumentElement());
}
public static <E> boolean isInEditingDocument(ElementManager<E> mgr, E element) {
return Boolean.TRUE.equals(mgr.getProperty(AnnotationPainter.DOCUMENT_MODE, element));
}
/**
* Max "units of work" per render pass, before we defer. We want this to be
* a fair bit bigger than 1, because of the startup cost before we actually
* start doing said units of work.
*/
private static final int MAX_RUN_ITERATIONS = 80;
private static final int MANY_ITERATIONS = 2000;
/**
* Per-document paint worker
*
* Public API allows registering of per-document functions & keys, as opposed
* to global ones on the annotation painter.
*/
public static class DocPainter<N, E extends N, T extends N> {
// protect the task from the public api as a member variable
private final Scheduler.IncrementalTask task = new Scheduler.IncrementalTask() {
public boolean execute() {
return doRun(MAX_RUN_ITERATIONS);
}
};
// Aliases for parts of the bundle we need.
private final LocalDocument<N, E, T> localDoc;
private final LocationMapper<N> mapper;
private final TextNodeOrganiser<T> textNodeOrganiser;
private final ReadableDocumentView<N, E, T> persistentView;
private final ReadableDocumentView<N, E, T> hardView;
private final MutableAnnotationSet.Local localAnnotations;
private final PainterRegistry paintRegistry;
// State vars. Reinitialised on each call to execute().
private HashMap<String, Object> currentValues;
private int startLocation, endLocation;
private int chunkEnd;
private AnnotationCursor cursor;
private ReadableStringSet nextChangingKeys;
private Map<String, String> renderAttrs;
private boolean dead = false;
Map<String, Object> boundaryBefore;
Map<String, Object> boundaryAfter;
private DocPainter(DocumentContext<N, E, T> bundle, PainterRegistry paintRegistry) {
localDoc = bundle.annotatableContent();
mapper = bundle.locationMapper();
textNodeOrganiser = bundle.textNodeOrganiser();
persistentView = bundle.persistentView();
hardView = bundle.hardView();
localAnnotations = bundle.localAnnotations();
this.paintRegistry = paintRegistry;
}
private boolean doRun(final int maxIterations) {
if (dead) {
return false;
}
int docSize = mapper.size();
int maybeLocation = localAnnotations.firstAnnotationChange(
0, docSize, REPAINT_KEY, null);
if (maybeLocation == -1) {
maybeScheduledPainters.remove(this);
return false;
}
Point<N> point = mapper.locate(maybeLocation);
E containingAnnotator = getAnnotatingElement(point.getCanonicalNode());
N startNode;
if (containingAnnotator != null) {
// TODO(danilatos): Optimise by using equality comparison with an end node, but
// be careful because sometimes the code below skips several nodes at a time, so
// maybe still do a location comparison in those cases.
N first = persistentView.getFirstChild(containingAnnotator);
N last = persistentView.getLastChild(containingAnnotator);
assert first != null : "We're supposed to be in this node, so it has at least one child";
startLocation = mapper.getLocation(first);
// Mark the entire range covered by the current bit of paint, in case we want to change
// it and its range exceeds the currently marked repaint range.
localAnnotations.setAnnotation(startLocation, mapper.getLocation(
Point.after(persistentView, last)), REPAINT_KEY, "y");
startNode = containingAnnotator;
} else {
startNode = ensureNodeBoundary(point);
startLocation = maybeLocation;
}
// Node we are up to, our "iterator" value.
N currentNode = startNode;
int remainingIterations = maxIterations;
endLocation = getEnd(startLocation, docSize, REPAINT_KEY, "y");
ReadableStringSet allKeys = getKeys();
nextChangingKeys = allKeys;
chunkEnd = startLocation;
currentValues = new HashMap<String, Object>();
cursor = localAnnotations.annotationCursor(startLocation, endLocation, allKeys);
progress();
N chunkEndNode = ensureNodeBoundary(mapper.locate(chunkEnd)); // exclusive
E lastBoundaryElement = null;
while (true) {
int currentLocation = currentNode == null ? docSize
: DocHelper.getFilteredLocation(mapper, persistentView,
Point.before(localDoc, currentNode));
while (chunkEnd <= currentLocation && chunkEnd < endLocation) {
Point<N> boundaryPoint = mapper.locate(chunkEnd);
progress();
chunkEndNode = ensureNodeBoundary(mapper.locate(chunkEnd));
// Do boundary rendering
E boundaryParent;
N boundaryNodeAfter;
// Convert a text point to a parent/nodeAfter pair
if (boundaryPoint.isInTextNode()) {
T textNode = hardView.asText(boundaryPoint.getContainer());
boolean isAtStartOfTextNode = boundaryPoint.getTextOffset() == 0;
assert isAtStartOfTextNode ||
boundaryPoint.getTextOffset() == localDoc.getLength(textNode)
: "Boundary point not at node boundary! "
+ localDoc.getData(textNode) + ":" + boundaryPoint.getTextOffset();
// (a) NOTE(danilatos): This slicing (and in the else block) is so that we
// can make the assumption later on, see corresponding (a) below.
boundaryNodeAfter = localDoc.transparentSlice(
isAtStartOfTextNode ? textNode : hardView.getNextSibling(textNode));
boundaryParent = boundaryNodeAfter != null
? localDoc.getParentElement(boundaryNodeAfter)
: hardView.getParentElement(textNode);
} else {
boundaryNodeAfter = boundaryPoint.getNodeAfter();
if (boundaryNodeAfter != null) {
boundaryNodeAfter = localDoc.transparentSlice(boundaryNodeAfter);
}
boundaryParent = boundaryNodeAfter != null
? localDoc.getParentElement(boundaryNodeAfter)
: localDoc.asElement(boundaryPoint.getContainer());
}
// maybe create a boundary element
lastBoundaryElement = getBoundaryElement(boundaryParent, boundaryNodeAfter);
if (chunkEnd == currentLocation) {
break;
}
}
if (currentLocation >= endLocation || remainingIterations <= 0) {
localAnnotations.setAnnotation(startLocation, currentLocation, REPAINT_KEY, null);
// TODO(danilatos): Conditionally break only if i >= maxIterations, otherwise
// find next range to repaint.
break;
}
N next;
E element = localDoc.asElement(currentNode);
if (element == null) {
// Wrap adjacent text nodes up
N fromIncl = currentNode;
N toExcl = fromIncl;
while (localDoc.asText(toExcl = localDoc.getNextSibling(toExcl)) != null) {
if (localDoc.isSameNode(chunkEndNode, toExcl)) {
break;
}
}
if (renderAttrs.size() > 0) {
next = getNextNode(wrap(renderAttrs, fromIncl, toExcl));
} else {
next = toExcl != null ? toExcl : getNextNode(localDoc.getParentElement(currentNode));
}
} else if (isPaintElement(element)) {
// TODO(danilatos): passing a transparent element to a regular traversal method.
// This will currently work, but we might get exceptions thrown if we add that
// to the behaviour of filtered view. The intended behaviour here is that
// we get the last visible node that is a child of element, or null if none.
N firstPersistentChild = hardView.getFirstChild(element);
if (firstPersistentChild == null) {
next = getFirstNode(element);
localDoc.transparentUnwrap(element);
} else {
// Again, same here
N lastPersistentChild = hardView.getLastChild(element);
int annotatorEnd = mapper.getLocation(Point.after(persistentView, lastPersistentChild));
if (annotatorEnd > chunkEnd || !rendersSame(renderAttrs, element)) {
// If this node is stale, or
// if this node was annotating a range further than the next render change,
// then it must be removed and be replaced by smaller bits. We also need to
// mark its encompassing range as to-be-repainted.
next = getFirstNode(element);
localDoc.transparentUnwrap(element);
localAnnotations.setAnnotation(currentLocation, annotatorEnd, REPAINT_KEY, "y");
} else {
// Otherwise, its range is done, so just skip it
next = getNextNode(element);
// (a) Assumption about boundary elements not being inside paint elements
// allows us to safely skip over the current element, a nice optimisation.
// This will not be as easy later when we have prioritised paint nodes.
// See corresponding (a) above for why we can make this assumption.
}
}
} else if (isBoundaryElement(element)) {
next = getNextNode(element);
// If it's a boundary element, we want to strip it out, unless it's one we just
// made. Ones we made earlier are further back, so it shouldn't be possible that
// we've come across one of those. It might not even be possible that we even
// come across the one we just made...
// TODO(danilatos): Test if this check is necessary (and/or sufficient...)
if (element != lastBoundaryElement) {
localDoc.transparentDeepRemove(element);
}
} else {
next = DocHelper.getNextNodeDepthFirst(localDoc, element, null, true);
}
currentNode = next;
remainingIterations--;
}
return true;
}
private void progress() {
ReadableStringSet changingKeys;
final int start = chunkEnd;
if (!cursor.hasNext()) {
chunkEnd = endLocation;
changingKeys = null;
} else {
changingKeys = cursor.nextLocation();
chunkEnd = cursor.currentLocation();
}
// TODO(danilatos): More efficient, too much hashmap munging.
boundaryBefore = new HashMap<String, Object>();
boundaryAfter = new HashMap<String, Object>();
nextChangingKeys.each(new Proc() {
@Override
public void apply(String key) {
Object newValue = localAnnotations.getAnnotation(start, key);
boundaryBefore.put(key, currentValues.get(key));
boundaryAfter.put(key, newValue);
if (newValue == null) {
currentValues.remove(key);
} else {
currentValues.put(key, newValue);
}
}
});
nextChangingKeys = changingKeys;
computeRenderAttrs();
}
private int getEnd(int start, int end, String key, Object fromValue) {
int ret = localAnnotations.firstAnnotationChange(start, end, key, fromValue);
return ret == -1 ? end : ret;
}
private boolean rendersSame(Map<String, String> attrs, E annotatingElement) {
return attrs != null && attrs.equals(localDoc.getAttributes(annotatingElement));
}
private N getNextNode(N node) {
return DocHelper.getNextNodeDepthFirst(localDoc, node, null, false);
}
private N getFirstNode(N node) {
return DocHelper.getNextNodeDepthFirst(localDoc, node, null, true);
}
/**
* Ensures the given point is at a node boundary, possibly splitting a text
* node in order to do so, in which case a new point is returned.
*
* @param point
* @return a point at the same place as the input point, guaranteed to be at
* a node boundary.
*/
private N ensureNodeBoundary(Point<N> point) {
return DocHelper.ensureNodeBoundaryReturnNextNode(point, localDoc, textNodeOrganiser);
}
private E wrap(Map<String, String> attrs, N fromIncl, N toExcl) {
E el = localDoc.transparentCreate(paintRegistry.getPaintTagName(), attrs,
localDoc.getParentElement(fromIncl), fromIncl);
localDoc.transparentMove(el, fromIncl, toExcl, null);
return el;
}
private boolean isPaintElement(E element) {
return localDoc.getTagName(element).equals(paintRegistry.getPaintTagName());
}
private boolean isBoundaryElement(E element) {
return localDoc.getTagName(element).equals(paintRegistry.getBoundaryTagName());
}
private E getAnnotatingElement(N node) {
for (E parent = localDoc.getParentElement(node); parent != null;
parent = localDoc.getParentElement(parent)) {
if (isPaintElement(parent)) {
return parent;
}
}
return null;
}
private void computeRenderAttrs() {
renderAttrs = new HashMap<String, String>();
boolean isEditing = isEditing(localDoc);
for (PaintFunction func : paintRegistry.getPaintFunctions()) {
// TODO(danilatos): Make this better by hiding keys the function did not
// register for, and making the input map unchangeable by the fucntion.
renderAttrs.putAll(func.apply(currentValues, isEditing));
}
}
/**
* Maybe create a boundary element at the given point.
*
* @param parent
* @param nodeAfter
* @return a boundary rendering element, or null if none needed here
*/
private E getBoundaryElement(E parent, N nodeAfter) {
// The current parent our boundary functions are putting children in
E currentParent = parent;
// The boundary element. We lazily create it; the first time a function
// returns non-null, we create the element, put it in place, put the
// function's returned element as a child, and make the boundary element
// the current parent, so subsequent elements go into this container.
E boundaryContainerElement = null;
boolean isEditing = isEditing(localDoc);
for (BoundaryFunction func : paintRegistry.getBoundaryFunctions()) {
E result = func.apply(localDoc, currentParent, nodeAfter, boundaryBefore, boundaryAfter,
isEditing);
if (result != null && boundaryContainerElement == null) {
boundaryContainerElement = localDoc.transparentCreate(paintRegistry.getBoundaryTagName(),
Collections.<String, String>emptyMap(), currentParent, nodeAfter);
currentParent = boundaryContainerElement;
nodeAfter = null;
localDoc.transparentMove(currentParent, result, localDoc.getNextSibling(result), null);
PersistentContent.makeDeepTransparent(localDoc, boundaryContainerElement);
}
}
return boundaryContainerElement;
}
private ReadableStringSet getKeys() {
return paintRegistry.getKeys();
}
}
private static final Property<AnnotationPainter> PAINTER_PROP =
Property.mutable("annotation-painter");
private static final String REPAINT_KEY = Annotations.makeUniqueLocal("paint");
private static final Property<DocPainter<?,?,?>> DOC_PAINTER_PROP =
Property.mutable("doc-annotation-painter");
/**
* Function for mapping any annotation key-value pairs to paint render attributes
*/
public static interface PaintFunction {
Map<String, String> apply(Map<String, Object> from, boolean isEditing);
}
/**
* Function for mapping any change in annotation key-value pairs to boundary
* elements to be inserted in the html (tracked by wrapper nodse, of course)
*/
// TODO(danilatos): Also handle things like specifying left/right border on an
// adjacent paint element? Or is that more a paint style implemented cleverly?
public static interface BoundaryFunction {
/**
* Callback for rendering boundary elements.
*
* Must only create an element at the given point. Must return the created element,
* or null if none created.
*
* @param localDoc
* @param parent parent of to-be-created element
* @param nodeAfter next sibling of to-be-created-element
* @param before map of annotation values before the boundary
* @param after map of annotation values after the boundary
* @return the created element, or null if nothing created
*/
// TODO(danilatos): This is a potentially dangerous method if implemented incorrectly.
// Find a way to make it safe without the API becoming too cumbersome.
<N, E extends N, T extends N> E apply(LocalDocument<N, E, T> localDoc, E parent, N nodeAfter,
Map<String, Object> before, Map<String, Object> after, boolean isEditing);
}
private static final ConcurrentSet<DocPainter<?, ?, ?>> maybeScheduledPainters
= ConcurrentSet.create();
private final TimerService scheduler;
/**
* @param scheduler Used for asynchronously repainting
*/
public AnnotationPainter(TimerService scheduler) {
this.scheduler = scheduler;
}
/**
* Same as {@link #scheduleRepaint(DocumentContext, int, int)}, but attempts
* to find a painter for the given document context, and will only schedule a
* repaint if it finds one.
*/
public static <N, E extends N, T extends N> void maybeScheduleRepaint(
DocumentContext<N, E, T> bundle, int start, int end) {
AnnotationPainter painter = bundle.elementManager().getProperty(
PAINTER_PROP, bundle.document().getDocumentElement());
if (painter != null) {
painter.scheduleRepaint(bundle, start, end);
}
}
private static <N, E extends N, T extends N> void setPainterProp(DocumentContext<N, E, T> bundle,
AnnotationPainter painter) {
E docElement = bundle.document().getDocumentElement();
bundle.elementManager().setProperty(PAINTER_PROP, docElement, painter);
}
/**
* Don't use this unless you are EditorImpl code. Using it indiscriminantly
* can cause lots of problems and bugs.
*/
public static <N, E extends N, T extends N> boolean repaintNow(DocumentContext<N, E, T> bundle) {
E docElement = bundle.document().getDocumentElement();
DocPainter<?, ?, ?> docPainter = bundle.elementManager().getProperty(
DOC_PAINTER_PROP, docElement);
if (docPainter != null) {
return docPainter.doRun(MAX_RUN_ITERATIONS);
} else {
return false;
}
}
/**
* Don't use this unless you are playback code. Using it indiscriminantly
* can cause lots of problems and bugs.
*/
public static void hackFlush() {
maybeScheduledPainters.lock();
try {
for (DocPainter<?, ?, ?> docPainter : maybeScheduledPainters) {
flush(docPainter);
}
} finally {
maybeScheduledPainters.unlock();
}
}
/**
* Flushes any painting scheduled for a document.
*
* @param context document to paint
*/
public static <N> void flush(DocumentContext<N, ?, ?> context) {
flush(getDocPainter(context));
}
/**
* Runs a painter until completion.
*
* @param painter painter to run
*/
private static void flush(DocPainter<?, ?, ?> painter) {
while (painter.doRun(MANY_ITERATIONS)) { }
}
private void schedule(DocPainter<?, ?, ?> docPainter) {
scheduler.schedule(docPainter.task);
maybeScheduledPainters.add(docPainter);
}
/**
* Mark a region of the document as stale and in need of a repaint
*
* @param bundle everything we need to know about the current document
* @param start as per defined annotation range semantics
* @param end as per defined annotation range semantics
*/
public <N, E extends N, T extends N> void scheduleRepaint(
DocumentContext<N, E, T> bundle, int start, int end) {
setPainterProp(bundle, this);
// Expand re-render range by 3, so that boundary decorators will get rendered if at paint
// range boundaries. (Maybe do a nicer way later).
// HACK(user): Turns out 1 wasn't enough, not sure why but seems like the
// boundary node is not directly next to the annotated region, work out
// exactly what number to use here.
int size = bundle.document().size();
end = Math.min(size, end + 3);
start = Math.max(0, start - 3);
if (start == end) {
if (start == 0) {
if (end < size) {
end++;
}
} else {
start--;
}
}
assert start >= 0 && end >= start && size >= end;
bundle.localAnnotations().setAnnotation(start, end, REPAINT_KEY, "y");
DocPainter<?, ?, ?> docPainter = getDocPainter(bundle);
schedule(docPainter);
}
/**
* Return the doc painter for the given document context, perhaps creating
* it if one did not already exist
*
* @param bundle document context
* @return doc painter for given context
*/
public static <N, E extends N, T extends N> DocPainter<?, ?, ?> getDocPainter(
DocumentContext<N, E, T> bundle) {
E docElement = bundle.document().getDocumentElement();
DocPainter<?, ?, ?> docPainter = bundle.elementManager().getProperty(
DOC_PAINTER_PROP, docElement);
// HACK(user): Initializing this is tricky. We set this property in
// EditorImpl as soon as we have a document element. However, the document
// element is only constructed when we consume the initial operations.
// The initial operations trigger annotation handling which expect the
// DocPainter to be set. Thus, we lazily create a temporary DocPainter,
// until the real one is ready.
if (docPainter == null) {
docPainter = new DocPainter<N, E, T>(bundle, Editor.ROOT_PAINT_REGISTRY);
bundle.elementManager().setProperty(DOC_PAINTER_PROP, docElement, docPainter);
}
return docPainter;
}
public static <N, E extends N, T extends N> void createAndSetDocPainter(
DocumentContext<N, E, T> bundle, PainterRegistry painterRegistry) {
E docElement = bundle.document().getDocumentElement();
DocPainter<?, ?, ?> existing = bundle.elementManager().getProperty(
DOC_PAINTER_PROP, docElement);
// Cleanup existing if exists
if (existing != null) {
existing.dead = true;
}
DocPainter<?, ?, ?> docPainter = new DocPainter<N, E, T>(bundle, painterRegistry);
bundle.elementManager().setProperty(DOC_PAINTER_PROP, docElement, docPainter);
}
public static <N, E extends N, T extends N> void clearDocPainter(
DocumentContext<N, E, T> bundle) {
DocPainter<?, ?, ?> existing = bundle.elementManager().getProperty(
DOC_PAINTER_PROP, bundle.document().getDocumentElement());
// Cleanup existing if exists
if (existing != null) {
existing.dead = true;
}
}
/**
* Register paint rendering behaviour
*
* @param dependentKeys
* @param function
* @deprecated
*/
@Deprecated
public void registerPaintFunctionz(ReadableStringSet dependentKeys, PaintFunction function) {
Editor.ROOT_PAINT_REGISTRY.registerPaintFunction(dependentKeys, function);
}
/**
* Register boundary rendering behaviour
*
* @param dependentKeys
* @param function
* @deprecated
*/
@Deprecated
public void registerBoundaryFunctionz(
ReadableStringSet dependentKeys, BoundaryFunction function) {
Editor.ROOT_PAINT_REGISTRY.registerBoundaryFunction(dependentKeys, function);
}
}