// 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.editor.renderer;
import com.google.collide.client.editor.Buffer;
import com.google.collide.client.editor.Editor;
import com.google.collide.client.editor.ViewportModel;
import com.google.collide.client.editor.ViewportModel.Edge;
import com.google.collide.client.editor.renderer.Renderer.LineLifecycleListener;
import com.google.collide.client.testing.DebugAttributeSetter;
import com.google.collide.client.util.Elements;
import com.google.collide.json.shared.JsonArray;
import com.google.collide.shared.document.Document;
import com.google.collide.shared.document.Line;
import com.google.collide.shared.document.LineInfo;
import com.google.collide.shared.document.anchor.Anchor;
import com.google.collide.shared.document.anchor.AnchorManager;
import com.google.collide.shared.document.anchor.AnchorType;
import com.google.collide.shared.document.anchor.Anchor.RemovalStrategy;
import com.google.collide.shared.document.util.LineUtils;
import com.google.collide.shared.util.ListenerManager;
import com.google.collide.shared.util.ListenerManager.Dispatcher;
import com.google.gwt.user.client.Timer;
import elemental.css.CSSStyleDeclaration;
import elemental.html.Element;
import java.util.EnumSet;
/*
* TODO: I need to do another pass at the rendering paths after
* having written the first round. There some edge cases that I handle, but not
* in the cleanest way (these are mostly around joining lines at the top and
* bottom of the viewport.)
*/
// Note: All Lines in viewport's range will have a cached line number
/**
*/
public class ViewportRenderer {
/**
* The animation controller deals with edge cases caused by multiple
* animations being queued up, usually from the user holding down the enter or
* backspace key so lines have their top rapidly changed.
*
*/
private class AnimationController {
private final Editor.View editorView;
private boolean isAnimating = false;
private boolean isDisabled = false;
private final Timer animationFinishedTimer = new Timer() {
@Override
public void run() {
isAnimating = false;
if (isDisabled) {
// Re-enable animation
AnimationController.this.editorView.setAnimationEnabled(true);
isDisabled = false;
}
}
};
AnimationController(Editor.View editorView) {
this.editorView = editorView;
}
/**
* This is called right before a group of DOM changes that would cause an
* animation (ex: setting element top). If elements on the page are already
* in the middle of an animation, disable animations for a short period,
* then re-enable them. Keep rescheduling the re-enable timer on additional
* calls.
*/
void onBeforeAnimationStarted() {
if (isAnimating) {
if (!isDisabled) {
editorView.setAnimationEnabled(false);
isDisabled = true;
}
animationFinishedTimer.cancel();
} else {
isAnimating = true;
}
animationFinishedTimer.schedule(Editor.ANIMATION_DURATION);
}
}
/** Key for a {@link Line#getTag} that stores the rendered DOM element */
public static final String LINE_TAG_LINE_ELEMENT = "ViewportRenderer.element";
/**
* Key for a {@link Line#getTag} that stores a reference to the anchor that is
* used to cache the line number for this line (since we cache line numbers
* for lines in the viewport)
*/
private static final String LINE_TAG_LINE_NUMBER_CACHE_ANCHOR =
"ViewportRenderer.lineNumberCacheAnchor";
private static final AnchorType OLD_VIEWPORT_ANCHOR_TYPE = AnchorType.create(
ViewportRenderer.class, "Old viewport");
private static final AnchorType VIEWPORT_RENDERER_ANCHOR_TYPE = AnchorType.create(
ViewportRenderer.class, "viewport renderer");
public static boolean isRendered(Line line) {
return getLineElement(line) != null;
}
private static Element getLineElement(Line line) {
return line.getTag(LINE_TAG_LINE_ELEMENT);
}
/**
* Control queued animations by disabling future animations temporarily if
* another animation happens in here.
*/
private final AnimationController animationController;
private final Buffer buffer;
private final Document document;
private final ListenerManager<LineLifecycleListener> lineLifecycleListenerManager;
private final LineRendererController lineRendererController;
private final ViewportModel viewport;
/**
* The bottom of the viewport when last rendered, or null if the viewport
* hasn't been rendered yet
*/
private Anchor viewportOldBottomAnchor;
/**
* The top of the viewport when last rendered, or null if the viewport hasn't
* been rendered yet
*/
private Anchor viewportOldTopAnchor;
ViewportRenderer(Document document, Buffer buffer, ViewportModel viewport,
Editor.View editorView, ListenerManager<LineLifecycleListener> lineLifecycleListenerManager) {
this.document = document;
this.buffer = buffer;
this.lineLifecycleListenerManager = lineLifecycleListenerManager;
this.lineRendererController = new LineRendererController(buffer);
this.viewport = viewport;
this.animationController = new AnimationController(editorView);
}
private void placeOldViewportAnchors() {
AnchorManager anchorManager = document.getAnchorManager();
if (viewportOldTopAnchor == null) {
viewportOldTopAnchor =
anchorManager.createAnchor(OLD_VIEWPORT_ANCHOR_TYPE, viewport.getTopLine(),
viewport.getTopLineNumber(), AnchorManager.IGNORE_COLUMN);
viewportOldTopAnchor.setRemovalStrategy(RemovalStrategy.SHIFT);
viewportOldBottomAnchor =
anchorManager.createAnchor(OLD_VIEWPORT_ANCHOR_TYPE, viewport.getBottomLine(),
viewport.getBottomLineNumber(), AnchorManager.IGNORE_COLUMN);
viewportOldBottomAnchor.setRemovalStrategy(RemovalStrategy.SHIFT);
} else {
anchorManager.moveAnchor(viewportOldTopAnchor, viewport.getTopLine(),
viewport.getTopLineNumber(), AnchorManager.IGNORE_COLUMN);
anchorManager.moveAnchor(viewportOldBottomAnchor, viewport.getBottomLine(),
viewport.getBottomLineNumber(), AnchorManager.IGNORE_COLUMN);
}
}
void addLineRenderer(LineRenderer lineRenderer) {
lineRendererController.addLineRenderer(lineRenderer);
}
void removeLineRenderer(LineRenderer lineRenderer) {
lineRendererController.removeLineRenderer(lineRenderer);
}
void render() {
renderViewportShift(true);
}
/**
* Re-renders the lines marked as dirty. If the line was not previously
* rendered, it will not be rendered here.
*/
void renderDirtyLines(JsonArray<Line> dirtyLines) {
int maxLineLength = buffer.getMaxLineLength();
int newMaxLineLength = maxLineLength;
for (int i = 0, n = dirtyLines.size(); i < n; i++) {
Line line = dirtyLines.get(i);
Element lineElement = getLineElement(line);
if (lineElement == null) {
/*
* The line may have been in the viewport when marked as dirty, but it
* is not anymore
*/
continue;
}
if (buffer.hasLineElement(lineElement)) {
lineRendererController.renderLine(line, LineUtils.getCachedLineNumber(line), lineElement,
false);
}
int lineLength = line.getText().length();
if (lineLength > newMaxLineLength) {
newMaxLineLength = lineLength;
}
}
if (newMaxLineLength != maxLineLength) {
// TODO: need to shrink back down if the max line shrinks
buffer.setMaxLineLength(newMaxLineLength);
}
}
/**
* Renders changes to the content of the viewport at a line level, for example
* line removals or additions.
*
* @param removedLines these lines were in the viewport at time of removal
*/
void renderViewportContentChange(int beginLineNumber, JsonArray<Line> removedLines) {
// Garbage collect the elements of removed lines
for (int i = 0, n = removedLines.size(); i < n; i++) {
Line line = removedLines.get(i);
garbageCollectLine(line);
}
/*
* New lines being rendered were at +createOffset below the viewport before
* this render pass.
*/
/*
* TODO: This won't be correct if deletion DocOps from the
* frontend are also applied in the same rendering pass as a deletion.
*/
int createOffset = removedLines.size() * buffer.getEditorLineHeight();
if (beginLineNumber <= viewport.getBottomLineNumber()) {
// Only fill or update lines if the content change affects the viewport
LineInfo beginLine =
viewport.getDocument().getLineFinder().findLine(viewport.getBottom(), beginLineNumber);
animationController.onBeforeAnimationStarted();
fillOrUpdateLines(beginLine.line(), beginLine.number(), viewport.getBottomLine(),
viewport.getBottomLineNumber(), createOffset);
}
}
void renderViewportLineNumbersChanged(EnumSet<Edge> changedEdges) {
if (changedEdges.contains(ViewportModel.Edge.TOP)) {
/*
* Collaborator added/removed lines above us, update the viewport lines
* since their tops may have changed
*/
LineInfo top = viewport.getTop();
LineInfo bottom = viewport.getBottom();
fillOrUpdateLines(top.line(), top.number(), bottom.line(), bottom.number(), 0);
}
}
/**
* Renders changes to the viewport positioning.
*/
void renderViewportShift(boolean forceRerender) {
LineInfo oldTop = viewportOldTopAnchor != null ? viewportOldTopAnchor.getLineInfo() : null;
LineInfo oldBottom =
viewportOldBottomAnchor != null ? viewportOldBottomAnchor.getLineInfo() : null;
LineInfo top = viewport.getTop();
LineInfo bottom = viewport.getBottom();
if (oldTop == null || oldBottom == null || bottom.number() < oldTop.number()
|| top.number() > oldBottom.number() || forceRerender) {
/*
* The viewport does not overlap with its old position, GC old lines and
* render all of the new ones (or we are forced to rerender everything)
*/
if (oldTop != null && oldBottom != null) {
garbageCollectLines(oldTop.line(), oldTop.number(), oldBottom.line(), oldBottom.number());
}
fillOrUpdateLines(top.line(), top.number(), bottom.line(), bottom.number(), 0);
} else {
// There is some overlap, so be more efficient with our update
if (oldTop.number() < top.number()) {
// The viewport moved down, need to GC the offscreen lines
garbageCollectLines(top.line().getPreviousLine(), top.number() - 1, oldTop.line(),
oldTop.number());
} else if (oldTop.number() > top.number()) {
// The viewport moved up, need to fill with lines
fillOrUpdateLines(top.line(), top.number(), oldTop.line().getPreviousLine(),
oldTop.number() - 1, 0);
}
if (oldBottom.number() < bottom.number()) {
// The viewport moved down, need to fill with lines
fillOrUpdateLines(oldBottom.line().getNextLine(), oldBottom.number() + 1, bottom.line(),
bottom.number(), 0);
} else if (oldBottom.number() > bottom.number()) {
// The viewport moved up, need to GC the offscreen lines
garbageCollectLines(bottom.line().getNextLine(), bottom.number() + 1, oldBottom.line(),
oldBottom.number());
}
}
}
/**
* Once torn down, this instance cannot be used again.
*/
void teardown() {
}
/**
* Fills the buffer in the given range (inclusive).
*
* @param beginLineNumber the line number to start filling from
* @param endLineNumber the line number of last line (inclusive) to finish
* filling
* @param createOffset the offset in pixels that this line would be at before
* this rendering pass, used to animate in from offscreen
*/
public void fillOrUpdateLines(Line beginLine, int beginLineNumber, Line endLine,
int endLineNumber, int createOffset) {
Line curLine = beginLine;
int curLineNumber = beginLineNumber;
if (curLineNumber <= endLineNumber) {
for (; curLineNumber <= endLineNumber && curLine != null; curLineNumber++) {
createOrUpdateLineElement(curLine, curLineNumber, createOffset);
curLine = curLine.getNextLine();
}
} else {
for (; curLineNumber >= endLineNumber && curLine != null; curLineNumber--) {
createOrUpdateLineElement(curLine, curLineNumber, createOffset);
curLine = curLine.getPreviousLine();
}
}
}
private void garbageCollectLine(final Line line) {
lineLifecycleListenerManager.dispatch(new Dispatcher<Renderer.LineLifecycleListener>() {
@Override
public void dispatch(LineLifecycleListener listener) {
listener.onRenderedLineGarbageCollected(line);
}
});
Element element = line.getTag(LINE_TAG_LINE_ELEMENT);
if (element != null && buffer.hasLineElement(element)) {
element.removeFromParent();
line.putTag(LINE_TAG_LINE_ELEMENT, null);
}
handleLineLeftViewport(line);
}
/*
* TODO: consider taking LineInfo and a offset for each of begin
* and end. Callers right now do the offset manually, but that might lead to
* them giving us a null Line or out-of-bounds line number
*/
public void garbageCollectLines(Line beginLine, int beginNumber, Line endLine, int endNumber) {
if (beginNumber > endNumber) {
// Swap so beginNumber < endNumber
Line tmpLine = beginLine;
int tmpNumber = beginNumber;
beginLine = endLine;
beginNumber = endNumber;
endLine = tmpLine;
endNumber = tmpNumber;
}
Line curLine = beginLine;
for (int curNumber = beginNumber; curNumber <= endNumber && curLine != null; curNumber++) {
garbageCollectLine(curLine);
curLine = curLine.getNextLine();
}
}
private Element createOrUpdateLineElement(final Line line, final int lineNumber,
int createOffset) {
int top = buffer.calculateLineTop(lineNumber);
Element element = getLineElement(line);
boolean isCreatingElement = element == null;
if (isCreatingElement) {
element = Elements.createDivElement();
element.getStyle().setPosition(CSSStyleDeclaration.Position.ABSOLUTE);
lineRendererController.renderLine(line, lineNumber, element, true);
line.putTag(LINE_TAG_LINE_ELEMENT, element);
}
new DebugAttributeSetter().add("lineNum", Integer.toString(lineNumber)).on(element);
if (!buffer.hasLineElement(element)) {
element.getStyle().setTop(top + createOffset, CSSStyleDeclaration.Unit.PX);
buffer.addLineElement(element);
if (createOffset != 0) {
/*
* TODO: When enabling editor animations, reinvestigate
* need for below
*/
// Force a browser layout so CSS3 animations transition properly.
//element.getClientWidth();
element.getStyle().setTop(top, CSSStyleDeclaration.Unit.PX);
}
handleLineEnteredViewport(line, lineNumber, element);
} else {
element.getStyle().setTop(top, CSSStyleDeclaration.Unit.PX);
}
if (isCreatingElement) {
lineLifecycleListenerManager.dispatch(new Dispatcher<Renderer.LineLifecycleListener>() {
@Override
public void dispatch(LineLifecycleListener listener) {
listener.onRenderedLineCreated(line, lineNumber);
}
});
} else {
lineLifecycleListenerManager.dispatch(new Dispatcher<Renderer.LineLifecycleListener>() {
@Override
public void dispatch(LineLifecycleListener listener) {
listener.onRenderedLineShifted(line, lineNumber);
}
});
}
return element;
}
private void handleLineEnteredViewport(Line line, int lineNumber, Element lineElement) {
assert line.getTag(LINE_TAG_LINE_NUMBER_CACHE_ANCHOR) == null;
Anchor anchor =
line.getDocument()
.getAnchorManager()
.createAnchor(VIEWPORT_RENDERER_ANCHOR_TYPE, line, lineNumber,
AnchorManager.IGNORE_COLUMN);
// Stash this anchor as a line tag so we can remove it easily
line.putTag(LINE_TAG_LINE_NUMBER_CACHE_ANCHOR, anchor);
int lineLength = line.getText().length();
if (lineLength > buffer.getMaxLineLength()) {
buffer.setMaxLineLength(lineLength);
}
}
private void handleLineLeftViewport(Line line) {
Anchor anchor = line.getTag(LINE_TAG_LINE_NUMBER_CACHE_ANCHOR);
if (anchor == null) {
/*
* The line was in the viewport when the change occurred, but never
* rendered with it there, so it never got a line number cache anchor
* assigned
*/
return;
}
line.getDocument().getAnchorManager().removeAnchor(anchor);
line.putTag(LINE_TAG_LINE_NUMBER_CACHE_ANCHOR, null);
}
void handleRenderCompleted() {
placeOldViewportAnchors();
}
}