blob: 3b22f50a0740506f9c2cacc2a37fa645b12300c1 [file] [log] [blame]
/*******************************************************************************
* Copyright (c) 2015 Ericsson AB.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v10.html
*
* Contributors:
* Ericsson - initial API and implementation
*******************************************************************************/
package org.eclipse.egerrit.internal.ui.compare;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Iterator;
import org.eclipse.jface.text.BadLocationException;
import org.eclipse.jface.text.BadPositionCategoryException;
import org.eclipse.jface.text.DefaultPositionUpdater;
import org.eclipse.jface.text.DocumentEvent;
import org.eclipse.jface.text.IDocument;
import org.eclipse.jface.text.IDocumentListener;
import org.eclipse.jface.text.IPositionUpdater;
import org.eclipse.jface.text.IRegion;
import org.eclipse.jface.text.Position;
import org.eclipse.jface.text.TextUtilities;
import org.eclipse.jface.text.TextViewer;
import org.eclipse.jface.text.link.InclusivePositionUpdater;
import org.eclipse.jface.text.source.Annotation;
import org.eclipse.jface.text.source.AnnotationModel;
import org.eclipse.swt.SWT;
import org.eclipse.swt.custom.StyledText;
import org.eclipse.swt.events.KeyEvent;
import org.eclipse.swt.events.KeyListener;
import org.eclipse.swt.events.VerifyEvent;
import org.eclipse.swt.events.VerifyListener;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* This class is used to prevent the user to modify the source code or the comments that have already been published,
* and it also responsible to add/remove annotations representing the changes. In order to control what the user is
* typing in, we are monitoring the text widget through the VerifyEvent. In our case we listen to this event before it
* is actually taken into account which gives an opportunity to veto the changes. Also, because the undo/redo support is
* coming from jface text and not from the text widget itself, we have to listen to the document changed events.
*/
public class EditionLimiter implements VerifyListener, IDocumentListener {
private static Logger logger = LoggerFactory.getLogger(EditionLimiter.class);
private IDocument document;
private AnnotationModel annotations;
private TextViewer textViewer;
private StyledText textWidget;
//Flag to short-circuit the case where the input is triggered from the document model
private String lastTextForShortCircuiting = null;
//flag used to short-circuit the case where the event is coming from the widget
private boolean triggeredFromWidget = false;
public EditionLimiter(TextViewer viewer) {
this.textViewer = viewer;
textViewer.getTextWidget().addKeyListener(new KeyListener() {
@Override
public void keyPressed(KeyEvent e) {
if (((e.stateMask & SWT.CTRL) == SWT.CTRL) && (e.keyCode == 'd')
|| ((e.stateMask & SWT.COMMAND) == SWT.COMMAND) && (e.keyCode == 'd')) {
triggeredFromWidget = true;
StyledText txt = (StyledText) e.getSource();
int lineLength = 0;
int lineNo = 0;
int caretOffset = txt.getCaretOffset();
if (document == null) {
initialize();
}
try {
lineNo = document.getLineOfOffset(caretOffset);
lineLength = document.getLineLength(lineNo);
if (!isEditableLine(document.getLineOffset(lineNo), 1)) {
return;
}
IRegion regionForDeletedLine = document.getLineInformation(lineNo);
doit(regionForDeletedLine.getOffset(), lineLength, "", false, true); //$NON-NLS-1$
document.replace(document.getLineOffset(lineNo), lineLength, ""); //$NON-NLS-1$
} catch (BadLocationException e1) {
logger.debug("Exception while performing Ctrl-D", e1); //$NON-NLS-1$
return;
} finally {
lastTextForShortCircuiting = null;
triggeredFromWidget = false;
}
}
}
@Override
public void keyReleased(KeyEvent e) {
// ignore
}
});
}
private boolean doit(int start, int length, String text, boolean fromDoc, boolean deletionOnly) {
if (document == null) {
initialize();
}
printAnnotationsCount();
try {
//Text is deleted, check where this is happening
if ("".equals(text)) { //$NON-NLS-1$
return testEmptyText(start, length);
}
if (deletionOnly) {
return true;
}
//The user is typing text in a non-authorized area
if (!isEditableLine(start, text.length())) {
return testUnAuthorizeTextArea(start, text, fromDoc);
}
//When we reach this point, it means that the modification attempted by the user are authorized
return true;
} finally {
printAnnotationsCount();
}
}
/**
* @param start
* @param text
* @param fromDoc
* @return
*/
private boolean testUnAuthorizeTextArea(int start, String text, boolean fromDoc) {
boolean annotationAlreadyAdded = false;
int insertionPosition = start;
String commentText = text.trim();
if (!fromDoc) {
//Move insertion point to the next line if we are not inserting at the beginning of the line
//or move at the end of the comment if we are in a middle of a comment
if (!isBeginningOfLine(insertionPosition)
|| (isBeginningOfLine(insertionPosition) && getCurrentAnnotation(start) != null)) {
insertionPosition = setInsertPosToNextLine(start);
}
//We now have the final insertion position.
//If the document is writable at insertion position, we add content to the document.
if (isEditableLine(insertionPosition, text.length())) {
try {
document.replace(insertionPosition, 0, commentText);
textWidget.setCaretOffset(insertionPosition + commentText.length());
return false;
} catch (BadLocationException e) {
return false;
}
}
try {
document.replace(insertionPosition, 0, TextUtilities.getDefaultLineDelimiter(document));
annotations.addAnnotation(new GerritCommentAnnotation(null, commentText),
new Position(insertionPosition, 0));
annotationAlreadyAdded = true;
document.replace(insertionPosition, 0, commentText);
} catch (BadLocationException e) {
logger.debug("Exception inserting " + commentText, e); //$NON-NLS-1$
}
textWidget.setCaretOffset(insertionPosition + commentText.length());
}
if (!annotationAlreadyAdded) {
annotations.addAnnotation(new GerritCommentAnnotation(null, commentText),
new Position(insertionPosition, commentText.length()));
}
return false;
}
/**
* @param start
* @return
*/
private int setInsertPosToNextLine(int start) {
int insertionPosition;
GerritCommentAnnotation currentAnnotation = getCurrentAnnotation(start);
if (currentAnnotation != null) {
Position position = annotations.getPosition(currentAnnotation);
insertionPosition = getNextLine(position.getOffset() + position.getLength());
} else {
insertionPosition = getNextLine(start);
}
if (insertionPosition == -1) {
//If there is no next line, we first insert one, then compute it's position and proceed as usual
try {
document.replace(textWidget.getCharCount(), 0,
TextUtilities.getDefaultLineDelimiter(document));
} catch (BadLocationException e) {
// not possible
}
insertionPosition = getNextLine(start);
}
return insertionPosition;
}
/**
* @param start
* @param length
* @return
*/
private boolean testEmptyText(int start, int length) {
if (!isEditableLine(start, length)) {
return false;
}
Iterator<?> it = annotations.getAnnotationIterator(start, length, true, true);
Position impactedArea = new Position(start, length);
while (it.hasNext()) {
GerritCommentAnnotation comment = (GerritCommentAnnotation) it.next();
Position commentPosition = annotations.getPosition(comment);
try {
if (commentPosition.length == 0 && (document.get(start, length)
.equals(TextUtilities.getDefaultLineDelimiter(document)))) {
annotations.removeAnnotation(comment);
return true;
}
} catch (BadLocationException e) {
//Can't happen
}
//Bail if the impacted area is not completely included in the comment
if (!completelyIncludes(commentPosition, impactedArea)) {
return false; //we don't want the proposed modification to be performed
}
}
return true;
}
//Returns the offset of the line number after the line of the given offset
private int getNextLine(int offset) {
try {
return textWidget.getOffsetAtLine(textWidget.getLineAtOffset(offset) + 1);
} catch (java.lang.IllegalArgumentException e) {
return -1;
}
}
//Check if the offset represents the beginning of a line
private boolean isBeginningOfLine(int offset) {
return textWidget.getOffsetAtLine(textWidget.getLineAtOffset(offset)) == offset;
}
private boolean completelyIncludes(Position container, Position contained) {
return container.includes(contained.offset) && container.includes(contained.offset + contained.length - 1);
}
//A line is editable if it is a draft comment
private boolean isEditableLine(int offset, int length) {
Iterator<?> it = annotations.getAnnotationIterator(offset, length, true, true);
if (it.hasNext()) {
GerritCommentAnnotation annotation = (GerritCommentAnnotation) it.next();
if (annotation.getComment() == null || annotation.getComment().getAuthor() == null) {
return true;
}
}
return isInsertingAtTheEndOfExistingComment(offset);
}
private GerritCommentAnnotation getCurrentAnnotation(int offset) {
Iterator<Annotation> it = annotations.getAnnotationIterator();
while (it.hasNext()) {
GerritCommentAnnotation annotation = (GerritCommentAnnotation) it.next();
Position position = annotations.getPosition(annotation);
if (offset >= position.getOffset() && offset <= (position.getOffset() + position.getLength())) {
return annotation;
}
}
return null;
}
private boolean isInsertingAtTheEndOfExistingComment(int offset) {
Iterator<?> it = annotations.getAnnotationIterator();
while (it.hasNext()) {
GerritCommentAnnotation annotation = (GerritCommentAnnotation) it.next();
if (annotation.getComment() == null || annotation.getComment().getAuthor() == null) {
Position position = annotations.getPosition(annotation);
if (position.getOffset() + position.getLength() == offset) {
return true;
}
}
}
return false;
}
private void printAnnotationsCount() {
try {
logger.debug("Annotation count " + document.getPositions(IDocument.DEFAULT_CATEGORY).length); //$NON-NLS-1$
} catch (BadPositionCategoryException e) {
//ignore
}
}
@Override
public void verifyText(VerifyEvent e) {
textWidget = (StyledText) e.widget;
triggeredFromWidget = true;
e.doit = doit(e.start, e.end - e.start, e.text, false, false);
lastTextForShortCircuiting = e.text;
triggeredFromWidget = false;
}
@Override
public void documentAboutToBeChanged(DocumentEvent event) {
if (triggeredFromWidget) {
return;
}
if (event.getText().equals(lastTextForShortCircuiting)) {
return;
}
lastTextForShortCircuiting = event.getText();
doit(event.getOffset(), event.getLength(), event.fText, true, true);
}
@Override
public void documentChanged(DocumentEvent event) {
if (triggeredFromWidget) {
return;
}
if (event.getText().equals(lastTextForShortCircuiting)) {
lastTextForShortCircuiting = null;
return;
}
doit(event.getOffset(), event.getLength(), event.fText, true, false);
lastTextForShortCircuiting = null;
}
//Use an InclusivePositionUpdater instead of the default one.
//This way, when the user types in on the first character of the comment, the comment is grown.
private void changePositionUpdater() {
IPositionUpdater[] updaters = document.getPositionUpdaters();
//Make sure that the updater we want to add does not already exists
for (IPositionUpdater potentialInclusiveUpdater : updaters) {
if (potentialInclusiveUpdater instanceof InclusivePositionUpdater) {
return;
}
}
for (IPositionUpdater updater : updaters) {
if (updater instanceof DefaultPositionUpdater) {
try {
Method getCategory = DefaultPositionUpdater.class.getDeclaredMethod("getCategory", new Class[0]); //$NON-NLS-1$
getCategory.setAccessible(true);
if (IDocument.DEFAULT_CATEGORY.equals(getCategory.invoke(updater, new Object[0]))) {
document.removePositionUpdater(updater);
break;
}
} catch (NoSuchMethodException | SecurityException | IllegalAccessException | IllegalArgumentException
| InvocationTargetException e) {
e.printStackTrace();
}
}
}
document.addPositionUpdater(new InclusivePositionUpdater(IDocument.DEFAULT_CATEGORY));
}
private void initialize() {
document = textViewer.getDocument();
document.addDocumentListener(this);
annotations = ((CommentableCompareItem) textViewer.getDocument()).getEditableComments();
changePositionUpdater();
}
}