blob: ade2163438a3e78981a793d444cac6a36d22bf03 [file] [log] [blame]
/*******************************************************************************
* Copyright (c) 2004, 2013 John Krasnay and others.
* 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:
* John Krasnay - initial API and implementation
* Igor Jacy Lino Campista - Java 5 warnings fixed (bug 311325)
* Holger Voormann - bug 315914: content assist should only show elements
* valid in the current context
* Carsten Hiesserich - handling of elements within comments (bug 407801)
* Carsten Hiesserich - allow insertion of newline into pre elements (bug 407827)
* Carsten Hiesserich - handling of preformatted elements, XML insertion(bug 407827, bug 408501 )
* Carsten Hiesserich - added dispose()
* Carsten Hiesserich - flushing StyleSheet when content structure is changed
*******************************************************************************/
package org.eclipse.vex.core.internal.widget;
import java.io.IOException;
import java.net.URL;
import java.text.MessageFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Set;
import org.eclipse.core.runtime.Assert;
import org.eclipse.core.runtime.QualifiedName;
import org.eclipse.vex.core.XML;
import org.eclipse.vex.core.internal.core.Caret;
import org.eclipse.vex.core.internal.core.Color;
import org.eclipse.vex.core.internal.core.ElementName;
import org.eclipse.vex.core.internal.core.Graphics;
import org.eclipse.vex.core.internal.core.QualifiedNameComparator;
import org.eclipse.vex.core.internal.core.Rectangle;
import org.eclipse.vex.core.internal.css.IWhitespacePolicy;
import org.eclipse.vex.core.internal.css.StyleSheet;
import org.eclipse.vex.core.internal.css.StyleSheetReader;
import org.eclipse.vex.core.internal.dom.Document;
import org.eclipse.vex.core.internal.dom.Node;
import org.eclipse.vex.core.internal.io.XMLFragment;
import org.eclipse.vex.core.internal.layout.BlockBox;
import org.eclipse.vex.core.internal.layout.Box;
import org.eclipse.vex.core.internal.layout.BoxFactory;
import org.eclipse.vex.core.internal.layout.CssBoxFactory;
import org.eclipse.vex.core.internal.layout.LayoutContext;
import org.eclipse.vex.core.internal.layout.RootBox;
import org.eclipse.vex.core.internal.layout.VerticalRange;
import org.eclipse.vex.core.internal.undo.CannotRedoException;
import org.eclipse.vex.core.internal.undo.CannotUndoException;
import org.eclipse.vex.core.internal.undo.ChangeAttributeEdit;
import org.eclipse.vex.core.internal.undo.ChangeNamespaceEdit;
import org.eclipse.vex.core.internal.undo.CompoundEdit;
import org.eclipse.vex.core.internal.undo.DeleteEdit;
import org.eclipse.vex.core.internal.undo.EditProcessingInstructionEdit;
import org.eclipse.vex.core.internal.undo.IUndoableEdit;
import org.eclipse.vex.core.internal.undo.InsertCommentEdit;
import org.eclipse.vex.core.internal.undo.InsertElementEdit;
import org.eclipse.vex.core.internal.undo.InsertFragmentEdit;
import org.eclipse.vex.core.internal.undo.InsertProcessingInstructionEdit;
import org.eclipse.vex.core.internal.undo.InsertTextEdit;
import org.eclipse.vex.core.provisional.dom.AttributeChangeEvent;
import org.eclipse.vex.core.provisional.dom.BaseNodeVisitor;
import org.eclipse.vex.core.provisional.dom.BaseNodeVisitorWithResult;
import org.eclipse.vex.core.provisional.dom.ContentChangeEvent;
import org.eclipse.vex.core.provisional.dom.ContentRange;
import org.eclipse.vex.core.provisional.dom.DocumentValidationException;
import org.eclipse.vex.core.provisional.dom.Filters;
import org.eclipse.vex.core.provisional.dom.IAxis;
import org.eclipse.vex.core.provisional.dom.IComment;
import org.eclipse.vex.core.provisional.dom.IDocument;
import org.eclipse.vex.core.provisional.dom.IDocumentFragment;
import org.eclipse.vex.core.provisional.dom.IDocumentListener;
import org.eclipse.vex.core.provisional.dom.IElement;
import org.eclipse.vex.core.provisional.dom.INode;
import org.eclipse.vex.core.provisional.dom.IParent;
import org.eclipse.vex.core.provisional.dom.IPosition;
import org.eclipse.vex.core.provisional.dom.IProcessingInstruction;
import org.eclipse.vex.core.provisional.dom.IText;
import org.eclipse.vex.core.provisional.dom.IValidator;
import org.eclipse.vex.core.provisional.dom.NamespaceDeclarationChangeEvent;
/**
* A component that allows the display and edit of an XML document with an associated CSS stylesheet.
*/
public class BaseVexWidget implements IVexWidget {
/**
* Number of pixel rows above and below the caret that are rendered at a time.
*/
private static final int LAYOUT_WINDOW = 5000;
/**
* Because the height of each BlockElementBox is initially estimated, we sometimes have to try several times before
* the band being laid out is properly positioned about the offset. When the position of the offset changes by less
* than this amount between subsequent layout calls, the layout is considered stable.
*/
private static final int LAYOUT_TOLERANCE = 500;
/**
* Minimum layout width, in pixels. Prevents performance problems when width is very small.
*/
private static final int MIN_LAYOUT_WIDTH = 200;
private boolean debugging;
private boolean readOnly;
private final IHostComponent hostComponent;
private int layoutWidth = 500; // something reasonable to handle a document
// being set before the widget is sized
private IDocument document;
private StyleSheet styleSheet;
private IWhitespacePolicy whitespacePolicy = IWhitespacePolicy.NULL;
private final BoxFactory boxFactory = new CssBoxFactory();
private RootBox rootBox;
/** Stacks of UndoableEditEvents; items added and removed from end of list */
private LinkedList<UndoableAndOffset> undoList = new LinkedList<UndoableAndOffset>();
private LinkedList<UndoableAndOffset> redoList = new LinkedList<UndoableAndOffset>();
private static final int MAX_UNDO_STACK_SIZE = 100;
/** Support for beginWork/endWork */
private int beginWorkCount = 0;
private int beginWorkCaretOffset;
private CompoundEdit compoundEdit;
private int caretOffset;
private int mark;
private int selectionStart;
private int selectionEnd;
private INode currentNode;
private boolean caretVisible = true;
private Caret caret;
private Color caretColor;
// x offset to be maintained when moving vertically
private int magicX = -1;
private boolean antiAliased = false;
private final IDocumentListener documentListener = new IDocumentListener() {
@Override
public void attributeChanged(final AttributeChangeEvent e) {
invalidateElementBox(e.getParent());
/*
* Flush cached styles, since they might depend attribute values via conditional selectors.
*
* This cast is save because this event is only fired due to the attribute changes of elements.
*/
getStyleSheet().flushStyles(e.getParent());
BaseVexWidget.this.relayout();
fireSelectionChanged();
}
@Override
public void beforeContentDeleted(final ContentChangeEvent e) {
// Clean-up stylesheet cache
if (e.isStructuralChange()) {
final Iterator<? extends INode> childrenToDelete = e.getParent().children().withoutText().in(e.getRange()).iterator();
while (childrenToDelete.hasNext()) {
getStyleSheet().flushStyles(childrenToDelete.next());
}
}
}
@Override
public void beforeContentInserted(final ContentChangeEvent e) {
}
@Override
public void contentDeleted(final ContentChangeEvent e) {
flushStyles(e);
invalidateElementBox(e.getParent());
BaseVexWidget.this.relayout();
}
@Override
public void contentInserted(final ContentChangeEvent e) {
flushStyles(e);
invalidateElementBox(e.getParent());
BaseVexWidget.this.relayout();
}
@Override
public void namespaceChanged(final NamespaceDeclarationChangeEvent e) {
invalidateElementBox(e.getParent());
BaseVexWidget.this.relayout();
fireSelectionChanged();
}
private void flushStyles(final ContentChangeEvent e) {
if (e.isStructuralChange()) {
final StyleSheet styleSheet = getStyleSheet();
styleSheet.flushStyles(e.getParent());
// Flush styles of children before and after the changed content
final Iterator<? extends INode> childs = e.getParent().children().withoutText().in(e.getRange().resizeBy(-2, 2)).iterator();
while (childs.hasNext()) {
styleSheet.flushStyles(childs.next());
}
}
}
};
/**
* Class constructor.
*/
public BaseVexWidget(final IHostComponent hostComponent) {
this.hostComponent = hostComponent;
}
/**
* @see org.eclipse.swt.widgets.Widget#dispose()
*
*/
public void dispose() {
if (document != null) {
// Flushing the styles is not absolutely necessary, but without doing so,
// the entries in the StyleSheets cache map would only collected when the
// map is accessed agein by another instance.
getStyleSheet().flushAllStyles(document);
final IDocument doc = document;
doc.removeDocumentListener(documentListener);
}
styleSheet = null;
}
@Override
public void beginWork() {
if (beginWorkCount == 0) {
beginWorkCaretOffset = getCaretOffset();
compoundEdit = new CompoundEdit();
}
beginWorkCount++;
}
public void endWork(final boolean success) {
beginWorkCount--;
if (beginWorkCount == 0) {
// this.compoundEdit.end();
if (success) {
undoList.add(new UndoableAndOffset(compoundEdit, beginWorkCaretOffset));
if (undoList.size() > MAX_UNDO_STACK_SIZE) {
undoList.removeFirst();
}
redoList.clear();
relayout();
fireSelectionChanged();
} else {
try {
compoundEdit.undo();
this.moveTo(beginWorkCaretOffset);
} catch (final CannotUndoException e) {
// TODO: handle exception
}
}
compoundEdit = null;
}
}
private void beginSelection() {
beginWorkCount++;
}
private void endSelection() {
beginWorkCount--;
}
private boolean isInWorkBlock() {
return beginWorkCount > 0;
}
@Override
public boolean canInsertComment() {
if (readOnly) {
return false;
}
if (document == null) {
return false;
}
return document.canInsertComment(getCaretOffset());
}
@Override
public boolean canInsertProcessingInstruction() {
if (readOnly) {
return false;
}
if (document == null) {
return false;
}
return document.canInsertProcessingInstruction(getCaretOffset(), null);
}
@Override
public boolean canInsertFragment(final IDocumentFragment fragment) {
return canInsertAtCurrentSelection(fragment.getNodeNames());
}
@Override
public boolean canInsertText() {
return canReplaceCurrentSelectionWith(IValidator.PCDATA);
}
private boolean canReplaceCurrentSelectionWith(final QualifiedName... nodeNames) {
return canInsertAtCurrentSelection(Arrays.asList(nodeNames));
}
private boolean canInsertAtCurrentSelection(final List<QualifiedName> nodeNames) {
if (readOnly) {
return false;
}
if (document == null) {
return false;
}
final IValidator validator = document.getValidator();
if (validator == null) {
return true;
}
int startOffset = getCaretOffset();
int endOffset = getCaretOffset();
if (hasSelection()) {
startOffset = getSelectionStart();
endOffset = getSelectionEnd();
}
final IElement parent = document.getElementForInsertionAt(startOffset);
final List<QualifiedName> nodesBefore = Node.getNodeNames(parent.children().before(startOffset));
final List<QualifiedName> nodesAfter = Node.getNodeNames(parent.children().after(endOffset));
return validator.isValidSequence(parent.getQualifiedName(), nodesBefore, nodeNames, nodesAfter, true);
}
@Override
public boolean canPaste() {
throw new UnsupportedOperationException("Must be implemented in tookit-specific widget.");
}
@Override
public boolean canPasteText() {
throw new UnsupportedOperationException("Must be implemented in tookit-specific widget.");
}
@Override
public boolean canRedo() {
if (readOnly) {
return false;
}
return !redoList.isEmpty();
}
@Override
public boolean canUndo() {
if (readOnly) {
return false;
}
return !undoList.isEmpty();
}
@Override
public void copySelection() {
throw new UnsupportedOperationException("Must be implemented in tookit-specific widget.");
}
@Override
public void cutSelection() {
throw new UnsupportedOperationException("Must be implemented in tookit-specific widget.");
}
@Override
public void deleteNextChar() throws DocumentValidationException, ReadOnlyException {
if (readOnly) {
throw new ReadOnlyException("Cannot delete, because the editor is read-only.");
}
if (hasSelection()) {
deleteSelection();
} else {
final int offset = getCaretOffset();
final int n = document.getLength() - 1;
if (offset == n) {
// nop
} else if (isBetweenMatchingElements(offset)) {
joinElementsAt(offset);
} else if (isBetweenMatchingElements(offset + 1)) {
joinElementsAt(offset + 1);
} else if (document.getNodeForInsertionAt(offset).isEmpty()) {
// deleting the right sentinel of an empty element
// so just delete the whole element an move on
moveBy(1);
moveBy(-2, true);
deleteSelection();
} else if (document.getNodeForInsertionAt(offset + 1).isEmpty()) {
// deleting the left sentinel of an empty element
// so just delete the whole element an move on
moveBy(2, true);
deleteSelection();
} else if (!document.isTagAt(offset)) {
deleteNextToCaret();
}
}
}
@Override
public void deletePreviousChar() throws DocumentValidationException, ReadOnlyException {
if (readOnly) {
throw new ReadOnlyException("Cannot delete, because the editor is read-only.");
}
if (hasSelection()) {
deleteSelection();
} else {
int offset = getCaretOffset();
if (offset == 1) {
// nop
} else if (isBetweenMatchingElements(offset)) {
joinElementsAt(offset);
} else if (isBetweenMatchingElements(offset - 1)) {
joinElementsAt(offset - 1);
} else if (document.getNodeForInsertionAt(offset).isEmpty()) {
// deleting the left sentinel of an empty element
// so just delete the whole element an move on
moveBy(1);
moveBy(-2, true);
deleteSelection();
} else if (document.getNodeForInsertionAt(offset - 1).isEmpty()) {
// deleting the right sentinel of an empty element
// so just delete the whole element an move on
moveBy(-2, true);
deleteSelection();
} else {
offset--;
if (!document.isTagAt(offset)) {
deleteBeforeCaret();
}
}
}
}
@Override
public boolean canDeleteSelection() {
if (readOnly) {
return false;
}
if (!hasSelection()) {
return false;
}
return document.canDelete(getSelectedRange());
}
@Override
public void deleteSelection() throws ReadOnlyException {
if (readOnly) {
throw new ReadOnlyException("Cannot delete, because the editor is read-only.");
}
try {
if (hasSelection()) {
applyEdit(new DeleteEdit(document, getSelectedRange()), getSelectionEnd());
this.moveTo(getSelectionStart());
}
} catch (final DocumentValidationException e) {
e.printStackTrace(); // This should never happen, because we constrain the selection
}
}
private void deleteNextToCaret() {
try {
final int nextToCaret = getCaretOffset();
applyEdit(new DeleteEdit(document, new ContentRange(nextToCaret, nextToCaret)), nextToCaret);
this.moveTo(nextToCaret);
} catch (final DocumentValidationException e) {
e.printStackTrace(); // This should never happen, because we constrain the selection
}
}
private void deleteBeforeCaret() {
try {
final int beforeCaret = getCaretOffset() - 1;
applyEdit(new DeleteEdit(document, new ContentRange(beforeCaret, beforeCaret)), beforeCaret + 1);
this.moveTo(beforeCaret);
} catch (final DocumentValidationException e) {
e.printStackTrace(); // This should never happen, because we constrain the selection
}
}
@Override
public void doWork(final Runnable runnable) {
this.doWork(runnable, false);
}
@Override
public void doWork(final Runnable runnable, final boolean savePosition) {
IPosition position = null;
if (savePosition) {
position = document.createPosition(getCaretOffset());
}
boolean success = false;
try {
beginWork();
runnable.run();
success = true;
} catch (final Exception ex) {
ex.printStackTrace();
} finally {
endWork(success);
if (position != null) {
this.moveTo(position.getOffset());
}
}
}
private Box findInnermostBox(final IBoxFilter filter) {
return this.findInnermostBox(filter, getCaretOffset());
}
/**
* Returns the innermost box containing the given offset that matches the given filter.
*
* @param filter
* IBoxFilter that determines which box to return
* @param offset
* Document offset around which to search.
*/
private Box findInnermostBox(final IBoxFilter filter, final int offset) {
Box box = rootBox.getChildren()[0];
Box matchingBox = null;
for (;;) {
if (filter.matches(box)) {
matchingBox = box;
}
final Box original = box;
final Box[] children = box.getChildren();
for (final Box child : children) {
if (child.hasContent() && offset >= child.getStartOffset() && offset <= child.getEndOffset()) {
box = child;
break;
}
}
if (box == original) {
// No child found containing offset,
// so just return the latest match.
return matchingBox;
}
}
}
/**
* Returns the background color for the control, which is the same as the background color of the root element.
*/
public Color getBackgroundColor() {
return styleSheet.getStyles(document.getRootElement()).getBackgroundColor();
}
/**
* Returns the current caret.
*/
public Caret getCaret() {
return caret;
}
@Override
public int getCaretOffset() {
return caretOffset;
}
private int getStartOffset() {
if (hasSelection()) {
return getSelectionStart();
}
return getCaretOffset();
}
private int getEndOffset() {
if (hasSelection()) {
return getSelectionEnd();
}
return getCaretOffset();
}
@Override
public IElement getCurrentElement() {
return currentNode.accept(new BaseNodeVisitorWithResult<IElement>(null) {
@Override
public IElement visit(final IElement element) {
return element;
}
@Override
public IElement visit(final IComment comment) {
return comment.getParent().accept(this);
}
@Override
public IElement visit(final IText text) {
return text.getParent().accept(this);
}
@Override
public IElement visit(final IProcessingInstruction pi) {
return pi.getParent().accept(this);
}
});
}
@Override
public INode getCurrentNode() {
return currentNode;
}
@Override
public IDocument getDocument() {
return document;
}
/**
* Returns the natural height of the widget based on the current layout width.
*/
public int getHeight() {
return rootBox.getHeight();
}
@Override
public ElementName[] getValidInsertElements() {
if (readOnly) {
return new ElementName[0];
}
if (document == null) {
return new ElementName[0];
}
final IValidator validator = document.getValidator();
if (validator == null) {
return new ElementName[0];
}
final int startOffset = getStartOffset();
final int endOffset = getEndOffset();
final INode parentNode = document.getNodeForInsertionAt(startOffset);
final boolean parentNodeIsElement = Filters.elements().matches(parentNode);
if (!parentNodeIsElement) {
return new ElementName[0];
}
final IElement parent = (IElement) parentNode;
final List<QualifiedName> nodesBefore = Node.getNodeNames(parent.children().before(startOffset));
final List<QualifiedName> nodesAfter = Node.getNodeNames(parent.children().after(endOffset));
final List<QualifiedName> selectedNodes = Node.getNodeNames(parent.children().in(new ContentRange(startOffset, endOffset)));
final List<QualifiedName> candidates = createCandidatesList(validator, parent, IValidator.PCDATA);
filterInvalidSequences(validator, parent, nodesBefore, nodesAfter, candidates);
// If there's a selection, root out those candidates that can't contain the selection.
if (hasSelection()) {
filterInvalidSelectionParents(validator, selectedNodes, candidates);
}
Collections.sort(candidates, new QualifiedNameComparator());
final ElementName[] result = toElementNames(parent, candidates);
return result;
}
private static List<QualifiedName> createCandidatesList(final IValidator validator, final IElement parent, final QualifiedName... exceptions) {
final Set<QualifiedName> validItems = validator.getValidItems(parent);
final List<QualifiedName> exceptionItems = Arrays.asList(exceptions);
final List<QualifiedName> result = new ArrayList<QualifiedName>();
for (final QualifiedName validItem : validItems) {
if (!exceptionItems.contains(validItem)) {
result.add(validItem);
}
}
return result;
}
private static void filterInvalidSequences(final IValidator validator, final IElement parent, final List<QualifiedName> nodesBefore, final List<QualifiedName> nodesAfter,
final List<QualifiedName> candidates) {
final int sequenceLength = nodesBefore.size() + 1 + nodesAfter.size();
for (final Iterator<QualifiedName> iterator = candidates.iterator(); iterator.hasNext();) {
final QualifiedName candidate = iterator.next();
final List<QualifiedName> sequence = new ArrayList<QualifiedName>(sequenceLength);
sequence.addAll(nodesBefore);
sequence.add(candidate);
sequence.addAll(nodesAfter);
if (!canContainContent(validator, parent.getQualifiedName(), sequence)) {
iterator.remove();
}
}
}
private static void filterInvalidSelectionParents(final IValidator validator, final List<QualifiedName> selectedNodes, final List<QualifiedName> candidates) {
for (final Iterator<QualifiedName> iter = candidates.iterator(); iter.hasNext();) {
final QualifiedName candidate = iter.next();
if (!canContainContent(validator, candidate, selectedNodes)) {
iter.remove();
}
}
}
public boolean isAntiAliased() {
return antiAliased;
}
@Override
public boolean isDebugging() {
return debugging;
}
private int getSelectionEnd() {
return selectionEnd;
}
private int getSelectionStart() {
return selectionStart;
}
@Override
public ContentRange getSelectedRange() {
if (!hasSelection()) {
return new ContentRange(getCaretOffset(), getCaretOffset());
}
return new ContentRange(getSelectionStart(), getSelectionEnd() - 1);
}
@Override
public IDocumentFragment getSelectedFragment() {
if (hasSelection()) {
return document.getFragment(getSelectedRange());
} else {
return null;
}
}
@Override
public String getSelectedText() {
if (hasSelection()) {
return document.getText(getSelectedRange());
} else {
return "";
}
}
@Override
public StyleSheet getStyleSheet() {
return styleSheet;
}
@Override
public int getLayoutWidth() {
return layoutWidth;
}
public RootBox getRootBox() {
return rootBox;
}
@Override
public boolean hasSelection() {
return getSelectionStart() != getSelectionEnd();
}
@Override
public boolean canInsertElement(final QualifiedName elementName) {
return canReplaceCurrentSelectionWith(elementName);
}
@Override
public IElement insertElement(final QualifiedName elementName) throws DocumentValidationException, ReadOnlyException {
if (readOnly) {
throw new ReadOnlyException(MessageFormat.format("Cannot insert element {0}, because the editor is read-only.", elementName));
}
boolean success = false;
final IElement result;
try {
beginWork();
IDocumentFragment selectedFragment = null;
if (hasSelection()) {
selectedFragment = getSelectedFragment();
deleteSelection();
}
result = applyEdit(new InsertElementEdit(document, getCaretOffset(), elementName), getCaretOffset()).getElement();
this.moveTo(getCaretOffset() + 1);
if (selectedFragment != null) {
insertFragment(selectedFragment);
}
scrollCaretVisible();
success = true;
return result;
} finally {
endWork(success);
}
}
@Override
public void insertFragment(final IDocumentFragment fragment) throws DocumentValidationException {
if (readOnly) {
throw new ReadOnlyException("Cannot insert fragment, because the editor is read-only");
}
if (hasSelection()) {
deleteSelection();
}
final IElement surroundingElement = document.getElementForInsertionAt(getCaretOffset());
boolean success = false;
try {
beginWork();
applyEdit(new InsertFragmentEdit(document, getCaretOffset(), fragment), getCaretOffset());
final IPosition finalCaretPosition = document.createPosition(getCaretOffset() + fragment.getLength());
applyWhitespacePolicy(surroundingElement);
this.moveTo(finalCaretPosition.getOffset());
document.removePosition(finalCaretPosition);
success = true;
} finally {
endWork(success);
}
}
@Override
public void insertText(final String text) throws DocumentValidationException, ReadOnlyException {
if (readOnly) {
throw new ReadOnlyException("Cannot insert text, because the editor is read-only.");
}
if (hasSelection()) {
deleteSelection();
}
final IElement element = document.getElementForInsertionAt(getCaretOffset());
final boolean isPreformatted = whitespacePolicy.isPre(element);
final String toInsert;
if (!isPreformatted) {
toInsert = XML.compressWhitespace(XML.normalizeNewlines(text), true, true, true);
} else {
toInsert = text;
}
boolean success = false;
try {
beginWork();
int i = 0;
for (;;) {
final int nextLineBreak = toInsert.indexOf('\n', i);
if (nextLineBreak == -1) {
break;
}
if (nextLineBreak - i > 0) {
applyEdit(new InsertTextEdit(document, getCaretOffset(), toInsert.substring(i, nextLineBreak)), getCaretOffset());
}
this.moveTo(getCaretOffset() + nextLineBreak - i);
if (isPreformatted) {
applyEdit(new InsertTextEdit(document, getCaretOffset(), "\n"), getCaretOffset());
moveBy(1);
} else {
split();
}
i = nextLineBreak + 1;
}
if (i < toInsert.length()) {
applyEdit(new InsertTextEdit(document, getCaretOffset(), toInsert.substring(i)), getCaretOffset());
this.moveTo(getCaretOffset() + toInsert.length() - i);
}
success = true;
} finally {
endWork(success);
}
}
@Override
public void insertXML(final String xml) throws DocumentValidationException, ReadOnlyException {
if (readOnly) {
throw new ReadOnlyException("Cannot insert text, because the editor is read-only.");
}
final XMLFragment wrappedFragment = new XMLFragment(xml);
// If fragment contains only simple Text, use insertText to ensure consistent behavior
if (wrappedFragment.isTextOnly()) {
insertText(wrappedFragment.getXML());
return;
}
final IElement element = getBlockForInsertionAt(getCaretOffset());
final boolean isPreformatted = whitespacePolicy.isPre(element);
try {
final IDocumentFragment fragment = wrappedFragment.getDocumentFragment();
if (document.canInsertFragment(getCaretOffset(), fragment)) {
insertFragment(fragment);
} else if (document.canInsertText(getCaretOffset())) {
insertText(fragment.getText());
}
} catch (final DocumentValidationException e) {
// given XML is not valid - Insert text instead if target is preformatted
if (isPreformatted) {
insertText(wrappedFragment.getXML());
} else {
throw e;
}
}
}
private void applyWhitespacePolicy(final INode node) {
node.accept(new BaseNodeVisitor() {
@Override
public void visit(final IDocument document) {
document.children().accept(this);
}
@Override
public void visit(final IDocumentFragment fragment) {
fragment.children().accept(this);
}
@Override
public void visit(final IElement element) {
element.children().accept(this);
}
@Override
public void visit(final IText text) {
final IParent parentElement = text.ancestors().matching(Filters.elements()).first();
if (!whitespacePolicy.isPre(parentElement)) {
final String compressedContent = XML.compressWhitespace(text.getText(), false, false, false);
final ContentRange originalTextRange = text.getRange();
final CompoundEdit compoundEdit = new CompoundEdit();
compoundEdit.addEdit(new DeleteEdit(document, originalTextRange));
compoundEdit.addEdit(new InsertTextEdit(document, originalTextRange.getStartOffset(), compressedContent));
applyEdit(compoundEdit, originalTextRange.getStartOffset());
}
}
});
}
private IElement getBlockForInsertionAt(final int offset) {
final IElement element = document.getElementForInsertionAt(offset);
if (whitespacePolicy.isBlock(element)) {
return element;
}
for (final IParent parent : element.ancestors().matching(Filters.elements())) {
if (whitespacePolicy.isBlock(parent)) {
return (IElement) parent;
}
}
return null;
}
@Override
public void insertChar(final char c) throws DocumentValidationException, ReadOnlyException {
if (readOnly) {
throw new ReadOnlyException("Cannot insert a character, because the editor is read-only.");
}
if (hasSelection()) {
deleteSelection();
}
applyEdit(new InsertTextEdit(document, getCaretOffset(), Character.toString(c)), getCaretOffset());
this.moveBy(+1);
}
@Override
public IComment insertComment() throws DocumentValidationException, ReadOnlyException {
if (readOnly) {
throw new ReadOnlyException("Cannot insert comment, because the editor is read-only.");
}
Assert.isTrue(canInsertComment());
if (hasSelection()) {
deleteSelection();
}
boolean success = false;
try {
beginWork();
final InsertCommentEdit edit = applyEdit(new InsertCommentEdit(document, getCaretOffset()), getCaretOffset());
final IComment result = edit.getComment();
this.moveTo(getCaretOffset() + 1);
scrollCaretVisible();
success = true;
return result;
} finally {
endWork(success);
}
}
@Override
public IProcessingInstruction insertProcessingInstruction(final String target) throws DocumentValidationException, ReadOnlyException {
if (readOnly) {
throw new ReadOnlyException("Cannot insert processing instruction, because the editor is read-only.");
}
Assert.isTrue(canInsertProcessingInstruction());
boolean success = false;
try {
beginWork();
final InsertProcessingInstructionEdit edit = applyEdit(new InsertProcessingInstructionEdit(document, getCaretOffset(), target), getCaretOffset());
final IProcessingInstruction result = edit.getProcessingInstruction();
this.moveTo(getCaretOffset() + 1);
scrollCaretVisible();
success = true;
return result;
} finally {
endWork(success);
}
}
@Override
public void editProcessingInstruction(final String target, final String data) throws CannotRedoException, ReadOnlyException {
if (readOnly) {
throw new ReadOnlyException("Cannot change processing instruction, because the editor is read-only.");
}
final INode node = getCurrentNode();
if (!(node instanceof IProcessingInstruction)) {
throw new CannotRedoException("Current node is not a processing instruction");
}
boolean success = false;
try {
beginWork();
applyEdit(new EditProcessingInstructionEdit(document, getCaretOffset(), target, data), getCaretOffset());
this.moveTo(node.getStartOffset() + 1);
scrollCaretVisible();
success = true;
} finally {
endWork(success);
}
}
@Override
public boolean canUnwrap() {
if (readOnly) {
return false;
}
if (document == null) {
return false;
}
final IValidator validator = document.getValidator();
if (validator == null) {
return false;
}
final IElement element = document.getElementForInsertionAt(getCaretOffset());
final IElement parent = element.getParentElement();
if (parent == null) {
// can't unwrap the root
return false;
}
final List<QualifiedName> nodesBefore = Node.getNodeNames(parent.children().before(element.getStartOffset()));
final List<QualifiedName> newNodes = Node.getNodeNames(element.children());
final List<QualifiedName> nodesAfter = Node.getNodeNames(parent.children().after(element.getEndOffset()));
return validator.isValidSequence(parent.getQualifiedName(), nodesBefore, newNodes, nodesAfter, true);
}
@Override
public void unwrap() throws DocumentValidationException, ReadOnlyException {
if (readOnly) {
throw new ReadOnlyException("Cannot unwrap the element, because the editor is read-only.");
}
final int offset = getCaretOffset();
final IElement currentElement = document.getElementForInsertionAt(offset);
if (currentElement == document.getRootElement()) {
throw new DocumentValidationException("Cannot unwrap the root element.");
}
boolean success = false;
try {
beginWork();
this.moveTo(currentElement.getStartOffset() + 1, false);
this.moveTo(currentElement.getEndOffset(), true);
final IDocumentFragment frag = getSelectedFragment();
deleteSelection();
this.moveBy(-1, false);
this.moveBy(2, true);
deleteSelection();
if (frag != null) {
insertFragment(frag);
}
this.moveTo(offset - 1, false);
success = true;
} finally {
endWork(success);
}
}
@Override
public ElementName[] getValidMorphElements() {
final IElement currentElement = document.getElementForInsertionAt(getCaretOffset());
if (!canMorphElement(currentElement)) {
return new ElementName[0];
}
final IValidator validator = document.getValidator();
final IElement parent = currentElement.getParentElement();
final List<QualifiedName> candidates = createCandidatesList(validator, parent, IValidator.PCDATA, currentElement.getQualifiedName());
if (candidates.isEmpty()) {
return new ElementName[0];
}
final List<QualifiedName> content = Node.getNodeNames(currentElement.children());
final List<QualifiedName> nodesBefore = Node.getNodeNames(parent.children().before(currentElement.getStartOffset()));
final List<QualifiedName> nodesAfter = Node.getNodeNames(parent.children().after(currentElement.getEndOffset()));
for (final Iterator<QualifiedName> iter = candidates.iterator(); iter.hasNext();) {
final QualifiedName candidate = iter.next();
if (!canContainContent(validator, candidate, content)) {
iter.remove();
} else if (!isValidChild(validator, parent.getQualifiedName(), candidate, nodesBefore, nodesAfter)) {
iter.remove();
}
}
Collections.sort(candidates, new QualifiedNameComparator());
return toElementNames(parent, candidates);
}
private static ElementName[] toElementNames(final IElement parent, final List<QualifiedName> candidates) {
final ElementName[] result = new ElementName[candidates.size()];
int i = 0;
for (final QualifiedName candidate : candidates) {
result[i++] = new ElementName(candidate, parent.getNamespacePrefix(candidate.getQualifier()));
}
return result;
}
private boolean canMorphElement(final IElement element) {
if (readOnly) {
return false;
}
if (document == null) {
return false;
}
if (document.getValidator() == null) {
return false;
}
if (element.getParentElement() == null) {
return false;
}
if (element == document.getRootElement()) {
return false;
}
return true;
}
private static boolean canContainContent(final IValidator validator, final QualifiedName elementName, final List<QualifiedName> content) {
return validator.isValidSequence(elementName, content, true);
}
private static boolean isValidChild(final IValidator validator, final QualifiedName parentName, final QualifiedName elementName, final List<QualifiedName> nodesBefore,
final List<QualifiedName> nodesAfter) {
return validator.isValidSequence(parentName, nodesBefore, Arrays.asList(elementName), nodesAfter, true);
}
@Override
public boolean canMorph(final QualifiedName elementName) {
final IElement currentElement = document.getElementForInsertionAt(getCaretOffset());
if (!canMorphElement(currentElement)) {
return false;
}
final IValidator validator = document.getValidator();
if (!canContainContent(validator, elementName, Node.getNodeNames(currentElement.children()))) {
return false;
}
final IElement parent = currentElement.getParentElement();
final List<QualifiedName> nodesBefore = Node.getNodeNames(parent.children().before(currentElement.getStartOffset()));
final List<QualifiedName> nodesAfter = Node.getNodeNames(parent.children().after(currentElement.getEndOffset()));
return isValidChild(validator, parent.getQualifiedName(), elementName, nodesBefore, nodesAfter);
}
@Override
public void morph(final QualifiedName elementName) throws DocumentValidationException, ReadOnlyException {
if (readOnly) {
throw new ReadOnlyException(MessageFormat.format("Cannot morph to element {0}, because the editor is read-only.", elementName));
}
final int offset = getCaretOffset();
final IElement currentElement = document.getElementForInsertionAt(offset);
if (currentElement == document.getRootElement()) {
throw new DocumentValidationException("Cannot morph the root element.");
}
boolean success = false;
try {
beginWork();
this.moveTo(currentElement.getStartOffset() + 1, false);
this.moveTo(currentElement.getEndOffset(), true);
final IDocumentFragment frag = getSelectedFragment();
deleteSelection();
this.moveBy(-1, false);
this.moveBy(2, true);
deleteSelection();
insertElement(elementName);
if (frag != null) {
insertFragment(frag);
}
this.moveTo(offset, false);
success = true;
} finally {
endWork(success);
}
}
@Override
public void moveBy(final int distance) {
this.moveTo(getCaretOffset() + distance, false);
}
@Override
public void moveBy(final int distance, final boolean select) {
this.moveTo(getCaretOffset() + distance, select);
}
@Override
public void moveTo(final int offset) {
this.moveTo(offset, false);
}
@Override
public void moveTo(final int offset, final boolean select) {
if (!Document.isInsertionPointIn(document, offset)) {
return;
}
repaintCaret();
repaintSelectedRange();
if (select) {
moveSelectionTo(offset);
} else {
moveCaretTo(offset);
}
final INode oldNode = currentNode;
currentNode = document.getNodeForInsertionAt(caretOffset);
relayout();
final Graphics g = hostComponent.createDefaultGraphics();
final LayoutContext context = createLayoutContext(g);
caret = rootBox.getCaret(context, caretOffset);
INode node = currentNode;
if (node != oldNode) {
caretColor = Color.BLACK;
while (node != null) {
final Color bgColor = styleSheet.getStyles(node).getBackgroundColor();
if (bgColor != null) {
final int red = ~bgColor.getRed() & 0xff;
final int green = ~bgColor.getGreen() & 0xff;
final int blue = ~bgColor.getBlue() & 0xff;
caretColor = new Color(red, green, blue);
break;
}
node = node.getParent();
}
}
g.dispose();
magicX = -1;
scrollCaretVisible();
fireSelectionChanged();
caretVisible = true;
repaintSelectedRange();
}
private void moveSelectionTo(final int offset) {
final boolean movingForward = offset > caretOffset;
final boolean movingBackward = offset < caretOffset;
final boolean movingTowardMark = movingForward && mark >= offset || movingBackward && mark <= offset;
final boolean movingAwayFromMark = !movingTowardMark;
// expand or shrink the selection to make sure the selection is balanced
final int balancedStart = Math.min(mark, offset);
final int balancedEnd = Math.max(mark, offset);
final INode balancedNode = document.findCommonNode(balancedStart, balancedEnd);
if (movingForward && movingTowardMark) {
selectionStart = balanceForward(balancedStart, balancedNode);
selectionEnd = balanceForward(balancedEnd, balancedNode);
caretOffset = selectionStart;
} else if (movingBackward && movingTowardMark) {
selectionStart = balanceBackward(balancedStart, balancedNode);
selectionEnd = balanceBackward(balancedEnd, balancedNode);
caretOffset = selectionEnd;
} else if (movingForward && movingAwayFromMark) {
selectionStart = balanceBackward(balancedStart, balancedNode);
selectionEnd = balanceForward(balancedEnd, balancedNode);
caretOffset = selectionEnd;
} else if (movingBackward && movingAwayFromMark) {
selectionStart = balanceBackward(balancedStart, balancedNode);
selectionEnd = balanceForward(balancedEnd, balancedNode);
caretOffset = selectionStart;
}
}
private int balanceForward(final int offset, final INode balancedNode) {
int balancedOffset = offset;
INode node = document.getNodeForInsertionAt(balancedOffset);
while (node != balancedNode) {
balancedOffset = node.getEndOffset() + 1;
node = document.getNodeForInsertionAt(balancedOffset);
}
return balancedOffset;
}
private int balanceBackward(final int offset, final INode balancedNode) {
int balancedOffset = offset;
INode node = document.getNodeForInsertionAt(balancedOffset);
while (node != balancedNode) {
balancedOffset = node.getStartOffset();
node = document.getNodeForInsertionAt(balancedOffset);
}
return balancedOffset;
}
private void moveCaretTo(final int offset) {
selectionStart = offset;
selectionEnd = offset;
caretOffset = offset;
mark = offset;
}
@Override
public void moveToLineEnd(final boolean select) {
this.moveTo(rootBox.getLineEndOffset(getCaretOffset()), select);
}
@Override
public void moveToLineStart(final boolean select) {
this.moveTo(rootBox.getLineStartOffset(getCaretOffset()), select);
}
@Override
public void moveToNextLine(final boolean select) {
final int x = magicX == -1 ? caret.getBounds().getX() : magicX;
final Graphics g = hostComponent.createDefaultGraphics();
final int offset = rootBox.getNextLineOffset(createLayoutContext(g), getCaretOffset(), x);
g.dispose();
this.moveTo(offset, select);
magicX = x;
}
@Override
public void moveToNextPage(final boolean select) {
final int x = magicX == -1 ? caret.getBounds().getX() : magicX;
final int y = caret.getY() + Math.round(hostComponent.getViewport().getHeight() * 0.9f);
this.moveTo(viewToModel(x, y), select);
magicX = x;
}
@Override
public void moveToNextWord(final boolean select) {
final int n = document.getLength() - 1;
int offset = getCaretOffset();
while (offset < n && !Character.isLetterOrDigit(document.getCharacterAt(offset))) {
offset++;
}
while (offset < n && Character.isLetterOrDigit(document.getCharacterAt(offset))) {
offset++;
}
this.moveTo(offset, select);
}
@Override
public void moveToPreviousLine(final boolean select) {
final int x = magicX == -1 ? caret.getBounds().getX() : magicX;
final Graphics g = hostComponent.createDefaultGraphics();
final int offset = rootBox.getPreviousLineOffset(createLayoutContext(g), getCaretOffset(), x);
g.dispose();
this.moveTo(offset, select);
magicX = x;
}
@Override
public void moveToPreviousPage(final boolean select) {
final int x = magicX == -1 ? caret.getBounds().getX() : magicX;
final int y = caret.getY() - Math.round(hostComponent.getViewport().getHeight() * 0.9f);
this.moveTo(viewToModel(x, y), select);
magicX = x;
}
@Override
public void moveToPreviousWord(final boolean select) {
int offset = getCaretOffset();
while (offset > 1 && !Character.isLetterOrDigit(document.getCharacterAt(offset - 1))) {
offset--;
}
while (offset > 1 && Character.isLetterOrDigit(document.getCharacterAt(offset - 1))) {
offset--;
}
this.moveTo(offset, select);
}
private void select(final ContentRange range) {
if (!(Document.isInsertionPointIn(document, range.getStartOffset()) && Document.isInsertionPointIn(document, range.getEndOffset()))) {
return;
}
beginSelection();
moveTo(range.getStartOffset());
moveTo(range.getEndOffset(), true);
endSelection();
}
private void fireSelectionChanged() {
if (isInWorkBlock()) {
return;
}
hostComponent.fireSelectionChanged();
}
public void selectAll() {
select(document.getRange().resizeBy(1, -1));
}
public void selectWord() {
int startOffset = getCaretOffset();
int endOffset = getCaretOffset();
while (startOffset > 1 && Character.isLetterOrDigit(document.getCharacterAt(startOffset - 1))) {
startOffset--;
}
final int n = document.getLength() - 1;
while (endOffset < n && Character.isLetterOrDigit(document.getCharacterAt(endOffset))) {
endOffset++;
}
if (startOffset < endOffset) {
select(new ContentRange(startOffset, endOffset));
}
}
@Override
public void selectContentOf(final INode node) {
if (node.isEmpty()) {
moveTo(node.getEndOffset());
return;
}
select(node.getRange().resizeBy(1, 0));
}
@Override
public void select(final INode node) {
select(node.getRange());
}
/**
* Paints the contents of the widget in the given Graphics at the given point.
*
* @param g
* Graphics in which to draw the widget contents
* @param x
* x-coordinate at which to draw the widget
* @param y
* y-coordinate at which to draw the widget
*/
public void paint(final Graphics g, final int x, final int y) {
if (rootBox == null) {
return;
}
final LayoutContext context = createLayoutContext(g);
// Since we may be scrolling to sections of the document that have
// yet to be layed out, lay out any exposed area.
//
// TODO: this will probably be inaccurate, since we should really
// iterate the layout, but we don't have an offset around which
// to iterate...what to do, what to do....
final Rectangle rect = g.getClipBounds();
final int oldHeight = rootBox.getHeight();
rootBox.layout(context, rect.getY(), rect.getY() + rect.getHeight());
if (rootBox.getHeight() != oldHeight) {
hostComponent.setPreferredSize(rootBox.getWidth(), rootBox.getHeight());
}
rootBox.paint(context, 0, 0);
if (caretVisible) {
caret.draw(g, caretColor);
}
// Debug hash marks
/*
* ColorResource grey = g.createColor(new Color(160, 160, 160)); ColorResource oldColor = g.setColor(grey); for
* (int y2 = rect.getY() - rect.getY() % 50; y2 < rect.getY() + rect.getHeight(); y2 += 50) { g.drawLine(x, y +
* y2, x+10, y + y2); g.drawString(Integer.toString(y2), x + 15, y + y2 - 10); } g.setColor(oldColor);
* grey.dispose();
*/
}
@Override
public void paste() throws DocumentValidationException {
throw new UnsupportedOperationException("Must be implemented in tookit-specific widget.");
}
@Override
public void pasteText() throws DocumentValidationException {
throw new UnsupportedOperationException("Must be implemented in tookit-specific widget.");
}
@Override
public void redo() throws CannotRedoException, ReadOnlyException {
if (readOnly) {
throw new ReadOnlyException("Cannot redo, because the editor is read-only.");
}
if (redoList.isEmpty()) {
throw new CannotRedoException();
}
final UndoableAndOffset event = redoList.removeLast();
this.moveTo(event.caretOffset, false);
event.edit.redo();
undoList.add(event);
}
@Override
public void savePosition(final Runnable runnable) {
final IPosition pos = document.createPosition(getCaretOffset());
try {
runnable.run();
} finally {
this.moveTo(pos.getOffset());
}
}
/**
* Sets the value of the antiAliased flag.
*
* @param antiAliased
* if true, text is rendered using antialiasing.
*/
public void setAntiAliased(final boolean antiAliased) {
this.antiAliased = antiAliased;
}
@Override
public boolean canSetAttribute(final String attributeName, final String value) {
if (readOnly) {
return false;
}
final IElement element = getCurrentElement();
if (element == null) {
return false;
}
final QualifiedName qualifiedAttributeName = element.qualify(attributeName);
return element.canSetAttribute(qualifiedAttributeName, value);
}
@Override
public void setAttribute(final String attributeName, final String value) throws ReadOnlyException {
if (readOnly) {
throw new ReadOnlyException(MessageFormat.format("Cannot set attribute {0}, because the editor is read-only.", attributeName));
}
final IElement element = getCurrentElement();
if (element == null) {
return;
}
final QualifiedName qualifiedAttributeName = element.qualify(attributeName);
final String currentAttributeValue = element.getAttributeValue(qualifiedAttributeName);
if (value == null) {
removeAttribute(attributeName);
} else if (!value.equals(currentAttributeValue)) {
applyEdit(new ChangeAttributeEdit(document, getCaretOffset(), qualifiedAttributeName, currentAttributeValue, value), getCaretOffset());
}
}
@Override
public boolean canRemoveAttribute(final String attributeName) {
if (readOnly) {
return false;
}
final IElement element = getCurrentElement();
if (element == null) {
return false;
}
final QualifiedName qualifiedAttributeName = element.qualify(attributeName);
return element.canRemoveAttribute(qualifiedAttributeName);
}
@Override
public void removeAttribute(final String attributeName) throws ReadOnlyException {
if (readOnly) {
throw new ReadOnlyException(MessageFormat.format("Cannot remove attribute {0}, because the editor is read-only.", attributeName));
}
final IElement element = getCurrentElement();
if (element == null) {
return;
}
final QualifiedName qualifiedAttributeName = element.qualify(attributeName);
final String currentAttributeValue = element.getAttributeValue(qualifiedAttributeName);
if (currentAttributeValue != null) {
applyEdit(new ChangeAttributeEdit(document, getCaretOffset(), qualifiedAttributeName, currentAttributeValue, null), getCaretOffset());
}
}
@Override
public void setDebugging(final boolean debugging) {
this.debugging = debugging;
}
@Override
public boolean isReadOnly() {
return readOnly;
}
@Override
public void setReadOnly(final boolean readOnly) {
this.readOnly = readOnly;
}
@Override
public void setDocument(final IDocument document, final StyleSheet styleSheet) {
if (this.document != null) {
final IDocument doc = document;
doc.removeDocumentListener(documentListener);
}
this.document = document;
this.styleSheet = styleSheet;
undoList = new LinkedList<UndoableAndOffset>();
redoList = new LinkedList<UndoableAndOffset>();
beginWorkCount = 0;
compoundEdit = null;
createRootBox();
this.moveTo(this.document.getRootElement().getStartOffset() + 1);
this.document.addDocumentListener(documentListener);
}
/**
* Called by the host component when it gains or loses focus.
*
* @param focus
* true if the host component has focus
*/
public void setFocus(final boolean focus) {
caretVisible = true;
repaintCaret();
}
@Override
public void setLayoutWidth(int width) {
width = Math.max(width, MIN_LAYOUT_WIDTH);
if (document != null && width != getLayoutWidth()) {
// this.layoutWidth is set by relayoutAll
relayoutAll(width, styleSheet);
} else {
// maybe doc is null. Let's store layoutWidth so it's right
// when we set a doc
layoutWidth = width;
}
}
@Override
public void setStyleSheet(final StyleSheet styleSheet) {
if (document != null) {
relayoutAll(layoutWidth, styleSheet);
}
}
public void setStyleSheet(final URL ssUrl) throws IOException {
final StyleSheetReader reader = new StyleSheetReader();
final StyleSheet ss = reader.read(ssUrl);
this.setStyleSheet(ss);
}
@Override
public void setWhitespacePolicy(final IWhitespacePolicy whitespacePolicy) {
if (whitespacePolicy == null) {
this.whitespacePolicy = IWhitespacePolicy.NULL;
} else {
this.whitespacePolicy = whitespacePolicy;
}
}
@Override
public IWhitespacePolicy getWhitespacePolicy() {
return whitespacePolicy;
}
@Override
public boolean canSplit() {
if (readOnly) {
return false;
}
if (document == null) {
return false;
}
final IValidator validator = document.getValidator();
if (validator == null) {
return true;
}
if (!Filters.elements().matches(currentNode)) {
return false;
}
final IElement element = (IElement) currentNode;
final IElement parent = element.getParentElement();
if (parent == null) {
return false;
}
final int startOffset = element.getStartOffset();
final int endOffset = element.getEndOffset();
final List<QualifiedName> nodesBefore = Node.getNodeNames(parent.children().before(startOffset));
final List<QualifiedName> newNodes = Arrays.asList(element.getQualifiedName(), element.getQualifiedName());
final List<QualifiedName> nodesAfter = Node.getNodeNames(parent.children().after(endOffset));
return validator.isValidSequence(parent.getQualifiedName(), nodesBefore, newNodes, nodesAfter, true);
}
@Override
public void split() throws DocumentValidationException, ReadOnlyException {
if (readOnly) {
throw new ReadOnlyException("Cannot split, because the editor is read-only.");
}
if (!Filters.elements().matches(currentNode)) {
throw new DocumentValidationException("Can only split elements.");
}
final IElement element = (IElement) currentNode;
final long start = System.currentTimeMillis();
boolean success = false;
try {
beginWork();
if (hasSelection()) {
deleteSelection();
}
final IDocumentFragment splittedFragment;
final boolean splitAtEnd = getCaretOffset() == element.getEndOffset();
if (!splitAtEnd) {
this.moveTo(element.getEndOffset(), true);
splittedFragment = getSelectedFragment();
deleteSelection();
} else {
splittedFragment = null;
}
// either way, we are now at the end offset for the element, let's move just outside
this.moveBy(1);
insertElement(element.getQualifiedName());
// TODO: clone attributes
if (splittedFragment != null) {
final int finalOffset = getCaretOffset();
insertFragment(splittedFragment);
this.moveTo(finalOffset, false);
}
success = true;
} finally {
endWork(success);
}
if (isDebugging()) {
final long end = System.currentTimeMillis();
System.out.println("split() took " + (end - start) + "ms");
}
}
/**
* Toggles the caret to produce a flashing caret effect. This method should be called from the GUI event thread at
* regular intervals.
*/
public void toggleCaret() {
caretVisible = !caretVisible;
repaintCaret();
}
@Override
public void undo() throws CannotUndoException, ReadOnlyException {
if (readOnly) {
throw new ReadOnlyException("Cannot undo, because the editor is read-only.");
}
if (undoList.isEmpty()) {
throw new CannotUndoException();
}
final UndoableAndOffset event = undoList.removeLast();
event.edit.undo();
this.moveTo(event.caretOffset, false);
redoList.add(event);
}
@Override
public int viewToModel(final int x, final int y) {
final Graphics g = hostComponent.createDefaultGraphics();
final LayoutContext context = createLayoutContext(g);
final int offset = rootBox.viewToModel(context, x, y);
g.dispose();
return offset;
}
@Override
public void declareNamespace(final String namespacePrefix, final String namespaceURI) throws ReadOnlyException {
if (readOnly) {
throw new ReadOnlyException(MessageFormat.format("Cannot declare namespace {0}, because the editor is read-only.", namespacePrefix));
}
final IElement element = getCurrentElement();
if (element == null) {
// TODO throw IllegalStateException("Not in element");
return;
}
final String currentNamespaceURI = element.getNamespaceURI(namespacePrefix);
applyEdit(new ChangeNamespaceEdit(document, getCaretOffset(), namespacePrefix, currentNamespaceURI, namespaceURI), getCaretOffset());
}
@Override
public void removeNamespace(final String namespacePrefix) throws ReadOnlyException {
if (readOnly) {
throw new ReadOnlyException(MessageFormat.format("Cannot remove namespace {0}, because the editor is read-only.", namespacePrefix));
}
final IElement element = getCurrentElement();
if (element == null) {
// TODO throw IllegalStateException("Not in element");
return;
}
final String currentNamespaceURI = element.getNamespaceURI(namespacePrefix);
applyEdit(new ChangeNamespaceEdit(document, getCaretOffset(), namespacePrefix, currentNamespaceURI, null), getCaretOffset());
}
@Override
public void declareDefaultNamespace(final String namespaceURI) throws ReadOnlyException {
if (readOnly) {
throw new ReadOnlyException("Cannot declare default namespace, because the editor is read-only.");
}
final IElement element = getCurrentElement();
if (element == null) {
// TODO throw IllegalStateException("Not in element");
return;
}
final String currentNamespaceURI = element.getDefaultNamespaceURI();
applyEdit(new ChangeNamespaceEdit(document, getCaretOffset(), null, currentNamespaceURI, namespaceURI), getCaretOffset());
}
@Override
public void removeDefaultNamespace() throws ReadOnlyException {
if (readOnly) {
throw new ReadOnlyException("Cannot remove default namespace, because the editor is read-only.");
}
final IElement element = getCurrentElement();
if (element == null) {
// TODO throw IllegalStateException("Not in element");
return;
}
final String currentNamespaceURI = element.getDefaultNamespaceURI();
applyEdit(new ChangeNamespaceEdit(document, getCaretOffset(), null, currentNamespaceURI, null), getCaretOffset());
}
// ================================================== PRIVATE
/**
* Captures an UndoableAction and the offset at which it occurred.
*/
private static class UndoableAndOffset {
public IUndoableEdit edit;
public int caretOffset;
public UndoableAndOffset(final IUndoableEdit edit, final int caretOffset) {
this.edit = edit;
this.caretOffset = caretOffset;
}
}
private <T extends IUndoableEdit> T applyEdit(final T edit, final int caretOffset) {
addEdit(edit, caretOffset);
edit.redo();
return edit;
}
/**
* Processes the given edit, adding it to the undo stack.
*
* @param edit
* The edit to process.
* @param caretOffset
* Offset of the caret before the edit occurred. If the edit is undone, the caret is returned to this
* offset.
*/
private void addEdit(final IUndoableEdit edit, final int caretOffset) {
if (edit == null) {
return;
}
if (compoundEdit != null) {
compoundEdit.addEdit(edit);
} else if (!undoList.isEmpty() && undoList.getLast().edit.combine(edit)) {
return;
} else {
undoList.add(new UndoableAndOffset(edit, caretOffset));
if (undoList.size() > MAX_UNDO_STACK_SIZE) {
undoList.removeFirst();
}
redoList.clear();
}
}
/**
* Creates a layout context given a particular graphics context.
*
* @param g
* The graphics context to use for the layout context.
* @return the new layout context
*/
private LayoutContext createLayoutContext(final Graphics g) {
final LayoutContext context = new LayoutContext();
context.setBoxFactory(boxFactory);
context.setDocument(document);
context.setGraphics(g);
context.setStyleSheet(styleSheet);
if (hasSelection()) {
context.setSelectionStart(getSelectionStart());
context.setSelectionEnd(getSelectionEnd());
} else {
context.setSelectionStart(getCaretOffset());
context.setSelectionEnd(getCaretOffset());
}
return context;
}
private void createRootBox() {
final Graphics g = hostComponent.createDefaultGraphics();
final LayoutContext context = createLayoutContext(g);
rootBox = new RootBox(context, document, getLayoutWidth());
g.dispose();
}
/**
* Invalidates the box tree due to document changes. The lowest box that completely encloses the changed node is
* invalidated.
*
* @param node
* Node for which to search.
*/
private void invalidateElementBox(final INode node) {
final BlockBox elementBox = (BlockBox) this.findInnermostBox(new IBoxFilter() {
@Override
public boolean matches(final Box box) {
return box instanceof BlockBox && box.getNode() != null && box.getStartOffset() <= node.getStartOffset() + 1 && box.getEndOffset() >= node.getEndOffset();
}
});
if (elementBox != null) {
elementBox.invalidate(true);
}
}
/**
* Returns true if the given offset represents the boundary between two different elements with the same name and
* parent. This is used to determine if the elements can be joined via joinElementsAt.
*
* @param int offset The offset to check.
*/
private boolean isBetweenMatchingElements(final int offset) {
if (offset <= 1 || offset >= document.getLength() - 1) {
return false;
}
final IElement e1 = document.getElementForInsertionAt(offset - 1);
final IElement e2 = document.getElementForInsertionAt(offset + 1);
return e1 != e2 && e1 != null && e2 != null && e1.getParent() == e2.getParent() && e1.isKindOf(e2);
}
/**
* Calls layout() on the rootBox until the y-coordinate of a caret at the given offset converges, i.e. is less than
* LAYOUT_TOLERANCE pixels from the last call.
*
* @param offset
* Offset around which we should lay out boxes.
*/
private void iterateLayout(final int offset) {
VerticalRange repaintRange = null;
final Graphics g = hostComponent.createDefaultGraphics();
final LayoutContext context = createLayoutContext(g);
int layoutY = rootBox.getCaret(context, offset).getY();
while (true) {
final int oldLayoutY = layoutY;
final VerticalRange layoutRange = rootBox.layout(context, layoutY - LAYOUT_WINDOW / 2, layoutY + LAYOUT_WINDOW / 2);
if (layoutRange != null) {
if (repaintRange == null) {
repaintRange = layoutRange;
} else {
repaintRange = repaintRange.union(layoutRange);
}
}
layoutY = rootBox.getCaret(context, offset).getY();
if (Math.abs(layoutY - oldLayoutY) < LAYOUT_TOLERANCE) {
break;
}
}
g.dispose();
if (repaintRange == null || repaintRange.isEmpty()) {
return;
}
final Rectangle viewport = hostComponent.getViewport();
final VerticalRange viewportRange = new VerticalRange(viewport.getY(), viewport.getY() + viewport.getHeight());
if (repaintRange.intersects(viewportRange)) {
final VerticalRange intersection = repaintRange.intersection(viewportRange);
hostComponent.repaint(viewport.getX(), intersection.getTop(), viewport.getWidth(), intersection.getHeight());
}
}
@Override
public boolean canJoin() {
if (!hasSelection()) {
return false;
}
final IElement parent = document.getElementForInsertionAt(getCaretOffset());
final IAxis<? extends INode> selectedNodes = parent.children().in(getSelectedRange());
if (selectedNodes.isEmpty()) {
return false;
}
final IValidator validator = document.getValidator();
final INode firstNode = selectedNodes.first();
final List<QualifiedName> childNodeNames = new ArrayList<QualifiedName>();
int count = 0;
for (final INode selectedNode : selectedNodes) {
if (!selectedNode.isKindOf(firstNode)) {
return false;
}
childNodeNames.addAll(selectedNode.accept(new BaseNodeVisitorWithResult<List<QualifiedName>>(Collections.<QualifiedName> emptyList()) {
@Override
public List<QualifiedName> visit(final IElement element) {
return Node.getNodeNames(element.children());
}
}));
count++;
}
if (count <= 1) {
return false;
}
final boolean joinedChildrenValid = firstNode.accept(new BaseNodeVisitorWithResult<Boolean>(true) {
@Override
public Boolean visit(final IElement element) {
return validator.isValidSequence(element.getQualifiedName(), childNodeNames, true);
}
});
if (!joinedChildrenValid) {
return false;
}
return true;
}
@Override
public void join() throws DocumentValidationException {
if (!hasSelection()) {
return;
}
final IElement parent = document.getElementForInsertionAt(getCaretOffset());
final IAxis<? extends INode> selectedNodes = parent.children().in(getSelectedRange());
if (selectedNodes.isEmpty()) {
return;
}
final INode firstNode = selectedNodes.first();
final int selectionEnd = getSelectionEnd();
boolean success = false;
try {
beginWork();
final ArrayList<IDocumentFragment> contentToJoin = new ArrayList<IDocumentFragment>();
int count = 0;
for (final INode selectedNode : selectedNodes) {
if (!selectedNode.isKindOf(firstNode) && count > 0) {
throw new DocumentValidationException("Cannot join nodes of different kind.");
}
if (!selectedNode.isEmpty()) {
contentToJoin.add(document.getFragment(selectedNode.getRange().resizeBy(1, -1)));
}
count++;
}
if (count <= 1) {
return;
}
moveTo(firstNode.getEndOffset() + 1);
moveTo(selectionEnd, true);
deleteSelection();
moveTo(firstNode.getStartOffset() + 1);
moveTo(firstNode.getEndOffset(), true);
deleteSelection();
for (final IDocumentFragment preservedContent : contentToJoin) {
moveTo(firstNode.getEndOffset());
insertFragment(preservedContent);
}
success = true;
} finally {
endWork(success);
}
}
private void joinElementsAt(final int offset) throws DocumentValidationException {
boolean success = false;
try {
beginWork();
// get the second element
moveTo(offset + 1);
final IElement secondElement = getCurrentElement();
// preserve the second element's content
final boolean shouldMoveContent = !secondElement.isEmpty();
final IDocumentFragment preservedContent;
if (shouldMoveContent) {
moveTo(secondElement.getEndOffset(), true);
preservedContent = getSelectedFragment();
deleteSelection();
} else {
preservedContent = null;
}
// delete the empty element
moveBy(1);
moveBy(-2, true);
deleteSelection();
// insert the preserved content into the first element
moveBy(-1);
if (shouldMoveContent) {
final int savedOffset = getCaretOffset();
insertFragment(preservedContent);
moveTo(savedOffset, false);
}
success = true;
} finally {
endWork(success);
}
}
/**
* Lay out the area around the caret.
*/
private void relayout() {
if (isInWorkBlock()) {
return;
}
final long start = System.currentTimeMillis();
final int oldHeight = rootBox.getHeight();
iterateLayout(getCaretOffset());
if (rootBox.getHeight() != oldHeight) {
hostComponent.setPreferredSize(rootBox.getWidth(), rootBox.getHeight());
}
final Graphics g = hostComponent.createDefaultGraphics();
final LayoutContext context = createLayoutContext(g);
caret = rootBox.getCaret(context, getCaretOffset());
g.dispose();
if (isDebugging()) {
final long end = System.currentTimeMillis();
System.out.println("VexWidget layout took " + (end - start) + "ms");
}
}
/**
* Re-layout the entire widget, due to either a layout width change or a stylesheet range. This method does the
* actual setting of the width and stylesheet, since it needs to know where the caret is <i>before</i> the change,
* so that it can do a reasonable job of restoring the position of the viewport after the change.
*
* @param newWidth
* New width for the widget.
* @param newStyleSheet
* New stylesheet for the widget.
*/
private void relayoutAll(final int newWidth, final StyleSheet newStyleSheet) {
final Graphics g = hostComponent.createDefaultGraphics();
LayoutContext context = createLayoutContext(g);
final Rectangle viewport = hostComponent.getViewport();
// true if the caret is within the viewport
//
// TODO: incorrect if caret near the bottom and the viewport is
// shrinking
// To fix, we probably need to save the viewport height, just like
// we now store viewport width (as layout width).
final boolean caretVisible = viewport.intersects(caret.getBounds());
// distance from the top of the viewport to the top of the caret
// use this if the caret is visible in the viewport
int relCaretY = 0;
// offset around which we are laying out
// this is also where we put the top of the viewport if the caret
// isn't visible
int offset;
if (caretVisible) {
relCaretY = caret.getY() - viewport.getY();
offset = getCaretOffset();
} else {
offset = rootBox.viewToModel(context, 0, viewport.getY());
}
layoutWidth = newWidth;
styleSheet = newStyleSheet;
// Re-create the context, since it holds the old stylesheet
context = createLayoutContext(g);
createRootBox();
iterateLayout(offset);
hostComponent.setPreferredSize(rootBox.getWidth(), rootBox.getHeight());
caret = rootBox.getCaret(context, getCaretOffset());
if (caretVisible) {
int viewportY = caret.getY() - Math.min(relCaretY, viewport.getHeight());
viewportY = Math.min(rootBox.getHeight() - viewport.getHeight(), viewportY);
viewportY = Math.max(0, viewportY); // this must appear after the
// above line, since
// that line might set viewportY negative
hostComponent.scrollTo(viewport.getX(), viewportY);
scrollCaretVisible();
} else {
final int viewportY = rootBox.getCaret(context, offset).getY();
hostComponent.scrollTo(viewport.getX(), viewportY);
}
hostComponent.repaint();
g.dispose();
}
/**
* Repaints the area of the caret.
*/
private void repaintCaret() {
if (caret != null) {
// caret may be null when document is first set
final Rectangle bounds = caret.getBounds();
hostComponent.repaint(bounds.getX(), bounds.getY(), bounds.getWidth(), bounds.getHeight());
}
}
/**
* Repaints area of the control corresponding to a range of offsets in the document.
*/
private void repaintSelectedRange() {
if (isInWorkBlock()) {
return;
}
final Graphics g = hostComponent.createDefaultGraphics();
final LayoutContext context = createLayoutContext(g);
final Rectangle startBounds = rootBox.getCaret(context, getSelectionStart()).getBounds();
final int top1 = startBounds.getY();
final int bottom1 = top1 + startBounds.getHeight();
final Rectangle endBounds = rootBox.getCaret(context, getSelectionEnd()).getBounds();
final int top2 = endBounds.getY();
final int bottom2 = top2 + endBounds.getHeight();
final int top = Math.min(top1, top2);
final int bottom = Math.max(bottom1, bottom2);
if (top == bottom) {
// Account for zero-height horizontal carets
hostComponent.repaint(0, top - 1, getLayoutWidth(), bottom - top + 1);
} else {
hostComponent.repaint(0, top, getLayoutWidth(), bottom - top);
}
g.dispose();
}
private void scrollCaretVisible() {
final Rectangle caretBounds = caret.getBounds();
final Rectangle viewport = hostComponent.getViewport();
final int x = viewport.getX();
int y = 0;
final int offset = getCaretOffset();
if (offset == 1) {
y = 0;
} else if (offset == document.getLength() - 1) {
if (rootBox.getHeight() < viewport.getHeight()) {
y = 0;
} else {
y = rootBox.getHeight() - viewport.getHeight() + caret.getBounds().getHeight();
}
} else if (caretBounds.getY() < viewport.getY()) {
y = caretBounds.getY();
} else if (caretBounds.getY() + caretBounds.getHeight() > viewport.getY() + viewport.getHeight()) {
y = caretBounds.getY() + caretBounds.getHeight() - viewport.getHeight();
} else {
// no scrolling required
return;
}
hostComponent.scrollTo(x, y);
}
}