// 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;
import com.google.collide.client.editor.selection.SelectionModel;
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.LineFinder;
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.ListenerRegistrar;
import com.google.collide.shared.util.MathUtils;
import com.google.collide.shared.util.ListenerManager.Dispatcher;
import com.google.gwt.core.client.Scheduler;
import com.google.gwt.core.client.Scheduler.ScheduledCommand;
/**
* The model for the editor's viewport. This model also listens for events that
* affect the viewport, and adjusts the bounds accordingly. For example, it
* listens as a scroll listener and shifts the viewport when the user scrolls.
*
* The lifecycle of this class is tied to the current document in the editor. If
* the document is replaced, a new instance of this class is used for the new
* document.
*/
public class ViewportModel
implements
Buffer.ScrollListener,
Buffer.ResizeListener,
Document.LineListener,
SelectionModel.CursorListener,
Buffer.SpacerListener {
private static final AnchorType VIEWPORT_MODEL_ANCHOR_TYPE = AnchorType.create(
ViewportModel.class, "viewport model");
/**
* Listener that is notified of viewport changes.
*/
public interface Listener {
/**
* Called when the content of the viewport changes, for example a line gets
* added or removed. This will not be called if the text changes within a
* line.
*
* @param added true if the given {@code lineInfo} was added, false if
* removed (note that when removed, the lines will no longer be in
* the document)
* @param lines the lines added or removed (see the parameters on
* {@link Document.LineListener})
*/
void onViewportContentChanged(ViewportModel viewport, int lineNumber, boolean added,
JsonArray<Line> lines);
/**
* Called when the viewport is shifted, meaning at least one of its edges
* points to new lines.
*
* @param oldTop may be null if this is the first range set
* @param oldBottom may be null if this is the first range set
*/
void onViewportShifted(ViewportModel viewport, LineInfo top, LineInfo bottom, LineInfo oldTop,
LineInfo oldBottom);
/**
* Called when the line number of the viewport top or bottom changes.
*
* One example of where this differs from {@link #onViewportShifted} is if a
* collaborator is typing above our viewport. When she presses ENTER, it our
* viewport's line numbers will change, but will still point to the same
* line instances. Therefore, this method would be called but not
* {@link #onViewportShifted}.
*/
void onViewportLineNumberChanged(ViewportModel viewport, Edge edge);
}
public enum Edge {
TOP, BOTTOM
}
private class ChangeDispatcher implements ListenerManager.Dispatcher<Listener> {
private LineInfo oldTop;
private LineInfo oldBottom;
@Override
public void dispatch(Listener listener) {
listener.onViewportShifted(ViewportModel.this, topAnchor.getLineInfo(),
bottomAnchor.getLineInfo(), oldTop, oldBottom);
}
private void dispatch(LineInfo oldTop, LineInfo oldBottom) {
this.oldTop = oldTop;
this.oldBottom = oldBottom;
listenerManager.dispatch(this);
}
}
static ViewportModel create(Document document, SelectionModel selection, Buffer buffer) {
return new ViewportModel(document, selection, buffer);
}
private final Anchor.ShiftListener anchorShiftedListener =
new Anchor.ShiftListener() {
private final Dispatcher<Listener> lineNumberChangedDispatcher =
new ListenerManager.Dispatcher<ViewportModel.Listener>() {
@Override
public void dispatch(Listener listener) {
listener.onViewportLineNumberChanged(ViewportModel.this, curChangedEdge);
}
};
private Edge curChangedEdge;
@Override
public void onAnchorShifted(Anchor anchor) {
curChangedEdge = anchor == topAnchor ? Edge.TOP : Edge.BOTTOM;
listenerManager.dispatch(lineNumberChangedDispatcher);
}
};
private final AnchorManager anchorManager;
private Anchor bottomAnchor;
private final Buffer buffer;
private final Document document;
private final ChangeDispatcher changeDispatcher;
private final ListenerManager<Listener> listenerManager;
private final SelectionModel selection;
private Anchor topAnchor;
private final ListenerRegistrar.RemoverManager removerManager =
new ListenerRegistrar.RemoverManager();
private ViewportModel(Document document, SelectionModel selection, Buffer buffer) {
this.document = document;
this.anchorManager = document.getAnchorManager();
this.buffer = buffer;
this.changeDispatcher = new ChangeDispatcher();
this.listenerManager = ListenerManager.create();
this.selection = selection;
attachHandlers();
}
public LineInfo getBottom() {
return bottomAnchor.getLineInfo();
}
public Line getBottomLine() {
return bottomAnchor.getLine();
}
public int getBottomLineNumber() {
return bottomAnchor.getLineNumber();
}
public LineInfo getBottomLineInfo() {
return bottomAnchor.getLineInfo();
}
public Document getDocument() {
return document;
}
public ListenerRegistrar<Listener> getListenerRegistrar() {
return listenerManager;
}
public LineInfo getTop() {
return topAnchor.getLineInfo();
}
public Line getTopLine() {
return topAnchor.getLine();
}
public LineInfo getTopLineInfo() {
return topAnchor.getLineInfo();
}
public int getTopLineNumber() {
return topAnchor.getLineNumber();
}
public void initialize() {
resetPosition();
}
/**
* Returns whether the text of the given {@code lineNumber} is fully visible
* in the viewport, determined by the current scrollTop and height of the
* buffer.
*
* Note: This does not check whether any spacers above the given line are
* fully visible.
*/
public boolean isLineNumberFullyVisibleInViewport(int lineNumber) {
int lineTop = buffer.calculateLineTop(lineNumber);
int scrollTop = buffer.getScrollTop();
int lineHeight = buffer.getEditorLineHeight();
return lineTop >= scrollTop && (lineTop + lineHeight) <= scrollTop + buffer.getHeight();
}
public boolean isLineInViewport(Line line) {
// Lines in the viewport will always return a line number
int lineNumber = LineUtils.getCachedLineNumber(line);
return (lineNumber != -1) && isLineNumberInViewport(lineNumber);
}
public boolean isLineNumberInViewport(int lineNumber) {
/*
* TODO: fix this to only do the expensive
* calculation when a spacer is in the viewport.
*/
/*
* When the viewport is covered entirely by a spacer, the lines set as top
* and bottom aren't actually visible, so check using their absolute buffer
* positions.
*/
int bufferTop = buffer.getScrollTop();
int bufferBottom = bufferTop + buffer.getHeight();
int lineTop = buffer.calculateLineTop(lineNumber);
int lineBottom = lineTop + buffer.getEditorLineHeight();
return !(bufferTop > lineBottom || bufferBottom < lineTop);
}
@Override
public void onCursorChange(LineInfo lineInfo, int column, boolean isExplicitChange) {
int cursorLineNumber = lineInfo.number();
int cursorLeft = buffer.calculateColumnLeft(lineInfo.line(), column);
int cursorTop = buffer.calculateLineTop(cursorLineNumber);
int scrollLeft = buffer.getScrollLeft();
int scrollTop = buffer.getScrollTop();
int newScrollLeft = scrollLeft;
int newScrollTop = scrollTop;
int lineHeight = buffer.getEditorLineHeight();
int bufferHeight = buffer.getHeight();
int preCursorLineTop = Math.max(cursorTop - lineHeight, 0);
int postCursorLineBottom = cursorTop + 2 * lineHeight;
if (preCursorLineTop < scrollTop) {
newScrollTop = preCursorLineTop;
} else {
if (postCursorLineBottom > scrollTop + bufferHeight) {
newScrollTop = postCursorLineBottom - bufferHeight;
}
}
if (cursorLeft < scrollLeft) {
newScrollLeft = cursorLeft;
} else {
int bufferWidth = buffer.getWidth();
int columnWidth = (int) buffer.getEditorCharacterWidth();
if (cursorLeft + columnWidth > scrollLeft + bufferWidth) {
newScrollLeft = cursorLeft + columnWidth - bufferWidth;
}
}
if (newScrollTop != scrollTop || newScrollLeft != scrollLeft) {
setBufferScrollAsync(newScrollLeft, newScrollTop);
}
}
@Override
public void onLineAdded(Document document, final int lineNumber,
final JsonArray<Line> addedLines) {
if (adjustViewportBoundsForLineAdditionOrRemoval(document, lineNumber)) {
listenerManager.dispatch(new Dispatcher<ViewportModel.Listener>() {
@Override
public void dispatch(Listener listener) {
listener.onViewportContentChanged(ViewportModel.this, lineNumber, true, addedLines);
}
});
}
}
@Override
public void onLineRemoved(Document document, final int lineNumber,
final JsonArray<Line> removedLines) {
if (adjustViewportBoundsForLineAdditionOrRemoval(document, lineNumber)) {
listenerManager.dispatch(new Dispatcher<ViewportModel.Listener>() {
@Override
public void dispatch(Listener listener) {
listener.onViewportContentChanged(ViewportModel.this, lineNumber, false,
removedLines);
}
});
}
}
@Override
public void onScroll(Buffer buffer, int scrollTop) {
moveViewportToScrollTop(scrollTop);
}
@Override
public void onResize(Buffer buffer, int documentHeight, int viewportHeight, int scrollTop) {
moveViewportToScrollTop(scrollTop);
}
private void moveViewportToScrollTop(int scrollTop) {
int newTopLineNumber = buffer.convertYToLineNumber(scrollTop, true);
int numLinesToShow = buffer.getFlooredHeightInLines() + 1;
moveViewportToLineNumber(newTopLineNumber, numLinesToShow);
}
public void teardown() {
removeAnchors();
detachHandlers();
}
/**
* Adjusts the viewport bounds after a line is added or removed, returning
* whether there an adjustment was made.
*/
private boolean adjustViewportBoundsForLineAdditionOrRemoval(Document document, int lineNumber) {
int bottomLineNumber = bottomAnchor.getLineNumber();
int topLineNumber = topAnchor.getLineNumber();
int lastVisibleLineNumber = topLineNumber + buffer.getFlooredHeightInLines();
/*
* The "lastVisibleLineNumber != bottomLineNumber" catches the case where
* the viewport's last line is deleted, causing the bottom anchor to shift
* up a line before this method is called. So, the lineNumber will not be in
* the range of the top and bottom anchors.
*/
if (MathUtils.isInRangeInclusive(lineNumber, topLineNumber, bottomLineNumber)
|| lastVisibleLineNumber != bottomLineNumber) {
// Update the viewport to cope with the line addition or removal
int shiftAmount = lastVisibleLineNumber - bottomLineNumber;
if (shiftAmount != 0) {
shiftBottomAnchor(shiftAmount);
}
return true;
} else {
return false;
}
}
private void attachHandlers() {
removerManager.track(buffer.getScrollListenerRegistrar().add(this));
removerManager.track(buffer.getResizeListenerRegistrar().add(this));
removerManager.track(document.getLineListenerRegistrar().add(this));
removerManager.track(selection.getCursorListenerRegistrar().add(this));
removerManager.track(buffer.getSpacerListenerRegistrar().add(this));
}
private void detachHandlers() {
removerManager.remove();
}
private void moveViewportToLineNumber(int topLineNumber, int numLinesToShow) {
LineFinder lineFinder = buffer.getDocument().getLineFinder();
LineInfo newTop = lineFinder.findLine(getTop(), topLineNumber);
int targetBottomLineNumber = newTop.number() + numLinesToShow - 1;
LineInfo newBottom =
lineFinder.findLine(getBottom(),
Math.min(document.getLastLineNumber(), targetBottomLineNumber));
setRange(newTop, newBottom);
}
public void shiftHorizontally(boolean right) {
int deltaScrollLeft = right ? 20 : -20;
setBufferScrollAsync(buffer.getScrollLeft() + deltaScrollLeft, buffer.getScrollTop());
}
public void shiftVertically(boolean down, boolean byPage) {
int deltaAbsoluteScrollTop =
byPage ? buffer.getHeight() - buffer.getEditorLineHeight() : buffer.getEditorLineHeight();
int deltaScrollTop = down ? deltaAbsoluteScrollTop : -deltaAbsoluteScrollTop;
setBufferScrollAsync(buffer.getScrollLeft(), buffer.getScrollTop() + deltaScrollTop);
}
public void jumpTo(boolean end) {
int newScrollTop = end ? buffer.getScrollHeight() - buffer.getHeight() : 0;
setBufferScrollAsync(buffer.getScrollLeft(), newScrollTop);
}
private void removeAnchors() {
anchorManager.removeAnchor(topAnchor);
anchorManager.removeAnchor(bottomAnchor);
topAnchor = null;
bottomAnchor = null;
}
private void resetPosition() {
LineInfo firstLine = new LineInfo(document.getFirstLine(), 0);
int lastLineNumber = Math.min(document.getLastLineNumber(), buffer.getFlooredHeightInLines());
LineInfo lastLine = document.getLineFinder().findLine(lastLineNumber);
setRange(firstLine, lastLine);
}
/**
* Sets scroll top of the buffer asynchronously. This allows some events to be
* processed in the browser event loop between renders. (For example, without
* the asynchronous posting, holding down page down would only render one
* frame a second.)
*/
private void setBufferScrollAsync(final int scrollLeft, final int scrollTop) {
Scheduler.get().scheduleDeferred(new ScheduledCommand() {
@Override
public void execute() {
buffer.setScrollLeft(Math.max(0, scrollLeft));
/* We'll synchronously get a callback and shift our viewport model to this new scroll top */
buffer.setScrollTop(Math.max(0, scrollTop));
}
});
}
private void setRange(LineInfo newTop, LineInfo newBottom) {
LineInfo oldTop;
LineInfo oldBottom;
if (topAnchor == null || bottomAnchor == null) {
oldTop = oldBottom = null;
topAnchor =
anchorManager.createAnchor(VIEWPORT_MODEL_ANCHOR_TYPE, newTop.line(), newTop.number(),
AnchorManager.IGNORE_COLUMN);
topAnchor.setRemovalStrategy(RemovalStrategy.SHIFT);
topAnchor.getShiftListenerRegistrar().add(anchorShiftedListener);
bottomAnchor =
anchorManager.createAnchor(VIEWPORT_MODEL_ANCHOR_TYPE, newBottom.line(),
newBottom.number(), AnchorManager.IGNORE_COLUMN);
bottomAnchor.setRemovalStrategy(RemovalStrategy.SHIFT);
bottomAnchor.getShiftListenerRegistrar().add(anchorShiftedListener);
} else {
oldTop = topAnchor.getLineInfo();
oldBottom = bottomAnchor.getLineInfo();
if (oldTop.equals(newTop) && oldBottom.equals(newBottom)) {
return;
}
anchorManager.moveAnchor(topAnchor, newTop.line(), newTop.number(),
AnchorManager.IGNORE_COLUMN);
anchorManager.moveAnchor(bottomAnchor, newBottom.line(), newBottom.number(),
AnchorManager.IGNORE_COLUMN);
}
changeDispatcher.dispatch(oldTop, oldBottom);
}
/**
* @param lineCount if negative, the bottom anchor will shift upward that many
* lines
*/
private void shiftBottomAnchor(int lineCount) {
LineInfo bottomLineInfo = bottomAnchor.getLineInfo();
if (lineCount < 0) {
for (; lineCount < 0; lineCount++) {
bottomLineInfo.moveToPrevious();
}
} else {
for (; lineCount > 0; lineCount--) {
bottomLineInfo.moveToNext();
}
}
setRange(topAnchor.getLineInfo(), bottomLineInfo);
}
@Override
public void onSpacerAdded(Spacer spacer) {
updateBoundsAfterSpacerChanged(spacer.getLineNumber());
}
@Override
public void onSpacerHeightChanged(Spacer spacer, int oldHeight) {
updateBoundsAfterSpacerChanged(spacer.getLineNumber());
}
@Override
public void onSpacerRemoved(Spacer spacer, Line oldLine, int oldLineNumber) {
updateBoundsAfterSpacerChanged(oldLineNumber);
}
private void updateBoundsAfterSpacerChanged(int spacerLineNumber) {
if (spacerLineNumber < getTopLineNumber() || spacerLineNumber > getBottomLineNumber()) {
return;
}
int newBottomLineNumber =
buffer.convertYToLineNumber(buffer.getScrollTop() + buffer.getHeight(), true);
setRange(getTop(), document
.getLineFinder().findLine(getBottom(), newBottomLineNumber));
}
}