/*******************************************************************************
* Copyright (c) 2004, 2008 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
*******************************************************************************/
package net.sf.vex.widget;
import java.io.IOException;
import java.net.URL;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import javax.xml.parsers.ParserConfigurationException;
import net.sf.vex.core.Caret;
import net.sf.vex.core.Color;
import net.sf.vex.core.Graphics;
import net.sf.vex.core.IntRange;
import net.sf.vex.core.Rectangle;
import net.sf.vex.css.CSS;
import net.sf.vex.css.StyleSheet;
import net.sf.vex.css.StyleSheetReader;
import net.sf.vex.css.Styles;
import net.sf.vex.dom.Document;
import net.sf.vex.dom.DocumentEvent;
import net.sf.vex.dom.DocumentFragment;
import net.sf.vex.dom.DocumentListener;
import net.sf.vex.dom.DocumentReader;
import net.sf.vex.dom.DocumentValidationException;
import net.sf.vex.dom.Element;
import net.sf.vex.dom.IWhitespacePolicy;
import net.sf.vex.dom.IWhitespacePolicyFactory;
import net.sf.vex.dom.Position;
import net.sf.vex.dom.Validator;
import net.sf.vex.layout.BlockBox;
import net.sf.vex.layout.Box;
import net.sf.vex.layout.BoxFactory;
import net.sf.vex.layout.CssBoxFactory;
import net.sf.vex.layout.LayoutContext;
import net.sf.vex.layout.RootBox;
import net.sf.vex.undo.CannotRedoException;
import net.sf.vex.undo.CannotUndoException;
import net.sf.vex.undo.CompoundEdit;
import net.sf.vex.undo.IUndoableEdit;
import org.xml.sax.SAXException;
/**
* A Swing component that allows the display and edit of an XML
* document with an associated CSS stylesheet.
*/
public class VexWidgetImpl 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 HostComponent hostComponent;
private int layoutWidth = 500; // something reasonable to handle a document
// being set before the widget is sized
private Document document;
private StyleSheet styleSheet;
private BoxFactory boxFactory = new CssBoxFactory();
private RootBox rootBox;
/** Stacks of UndoableEditEvents; items added and removed from end of list */
private LinkedList undoList = new LinkedList();
private LinkedList redoList = new LinkedList();
private static final int MAX_UNDO_STACK_SIZE = 100;
private int undoDepth;
/** 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 Element currentElement;
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;
//======================================================= LISTENERS
private DocumentListener documentListener = new DocumentListener() {
public void attributeChanged(DocumentEvent e) {
invalidateElementBox(e.getParentElement());
// flush cached styles, since they might depend attribute values
// via conditional selectors
getStyleSheet().flushStyles(e.getParentElement());
if (beginWorkCount == 0) {
VexWidgetImpl.this.relayout();
}
addEdit(e.getUndoableEdit(), getCaretOffset());
hostComponent.fireSelectionChanged();
}
public void beforeContentDeleted(DocumentEvent e) {
}
public void beforeContentInserted(DocumentEvent e) {
}
public void contentDeleted(DocumentEvent e) {
invalidateElementBox(e.getParentElement());
if (beginWorkCount == 0) {
VexWidgetImpl.this.relayout();
}
addEdit(e.getUndoableEdit(), getCaretOffset());
}
public void contentInserted(DocumentEvent e) {
invalidateElementBox(e.getParentElement());
if (beginWorkCount == 0) {
VexWidgetImpl.this.relayout();
}
addEdit(e.getUndoableEdit(), getCaretOffset());
}
};
//======================================================= PUBLIC INTERFACE
/**
* Class constructor.
*/
public VexWidgetImpl(HostComponent hostComponent) {
this.hostComponent = hostComponent;
}
public void beginWork() {
if (this.beginWorkCount == 0) {
this.beginWorkCaretOffset = this.getCaretOffset();
this.compoundEdit = new CompoundEdit();
}
this.beginWorkCount++;
}
/**
* Returns true if the given fragment can be inserted at the current
* caret position.
* @param frag DocumentFragment to be inserted.
*/
public boolean canInsertFragment(DocumentFragment frag) {
Document doc = this.getDocument();
if (doc == null) {
return false;
}
Validator validator = doc.getValidator();
if (validator == null) {
return true;
}
int startOffset = this.getCaretOffset();
int endOffset = this.getCaretOffset();
if (this.hasSelection()) {
startOffset = this.getSelectionStart();
endOffset = this.getSelectionEnd();
}
Element parent = this.getDocument().getElementAt(startOffset);
String[] seq1 =
doc.getNodeNames(parent.getStartOffset() + 1, startOffset);
String[] seq2 = frag.getNodeNames();
String[] seq3 = doc.getNodeNames(endOffset, parent.getEndOffset());
return validator.isValidSequence(
parent.getName(),
seq1,
seq2,
seq3,
true);
}
/**
* Returns true if text can be inserted at the current position.
*/
public boolean canInsertText() {
Document doc = this.getDocument();
if (doc == null) {
return false;
}
Validator validator = this.document.getValidator();
if (validator == null) {
return true;
}
int startOffset = this.getCaretOffset();
int endOffset = this.getCaretOffset();
if (this.hasSelection()) {
startOffset = this.getSelectionStart();
endOffset = this.getSelectionEnd();
}
Element parent = this.getDocument().getElementAt(startOffset);
String[] seq1 =
doc.getNodeNames(parent.getStartOffset() + 1, startOffset);
String[] seq2 = new String[] { Validator.PCDATA };
String[] seq3 = doc.getNodeNames(endOffset, parent.getEndOffset());
return validator.isValidSequence(
parent.getName(),
seq1,
seq2,
seq3,
true);
}
public boolean canPaste() {
throw new UnsupportedOperationException("Must be implemented in tookit-specific widget.");
}
public boolean canPasteText() {
throw new UnsupportedOperationException("Must be implemented in tookit-specific widget.");
}
public boolean canRedo() {
return this.redoList.size() > 0;
}
public boolean canUndo() {
return this.undoList.size() > 0;
}
public boolean canUnwrap() {
Document doc = this.getDocument();
if (doc == null) {
return false;
}
Validator validator = doc.getValidator();
if (validator == null) {
return false;
}
Element element = doc.getElementAt(this.getCaretOffset());
Element parent = element.getParent();
if (parent == null) {
// can't unwrap the root
return false;
}
String[] seq1 =
doc.getNodeNames(
parent.getStartOffset() + 1,
element.getStartOffset());
String[] seq2 =
doc.getNodeNames(
element.getStartOffset() + 1,
element.getEndOffset());
String[] seq3 =
doc.getNodeNames(element.getEndOffset() + 1, parent.getEndOffset());
return validator.isValidSequence(
parent.getName(),
seq1,
seq2,
seq3,
true);
}
public void copySelection() {
throw new UnsupportedOperationException("Must be implemented in tookit-specific widget.");
}
public void cutSelection() {
throw new UnsupportedOperationException("Must be implemented in tookit-specific widget.");
}
public void deleteNextChar() throws DocumentValidationException {
if (this.hasSelection()) {
this.deleteSelection();
} else {
int offset = this.getCaretOffset();
Document doc = this.getDocument();
int n = doc.getLength() - 1;
Element element = doc.getElementAt(offset);
if (offset == n) {
// nop
} else if (this.isBetweenMatchingElements(offset)) {
this.joinElementsAt(offset);
} else if (this.isBetweenMatchingElements(offset + 1)) {
this.joinElementsAt(offset + 1);
} else if (element.isEmpty()) {
// deleting the right sentinel of an empty element
// so just delete the whole element an move on
this.moveTo(offset - 1, false);
this.moveTo(offset + 1, true);
this.deleteSelection();
} else if (doc.getElementAt(offset + 1).isEmpty()) {
// deleting the left sentinel of an empty element
// so just delete the whole element an move on
this.moveTo(offset + 2, true);
this.deleteSelection();
} else {
if (doc.getCharacterAt(offset) != 0) {
this.moveTo(offset, false);
this.moveTo(offset + 1, true);
this.deleteSelection();
}
}
}
}
public void deletePreviousChar() throws DocumentValidationException {
if (this.hasSelection()) {
this.deleteSelection();
} else {
int offset = this.getCaretOffset();
Document doc = this.getDocument();
Element element = doc.getElementAt(offset);
if (offset == 1) {
// nop
} else if (this.isBetweenMatchingElements(offset)) {
this.joinElementsAt(offset);
} else if (this.isBetweenMatchingElements(offset - 1)) {
this.joinElementsAt(offset - 1);
} else if (element.isEmpty()) {
// deleting the left sentinel of an empty element
// so just delete the whole element an move on
this.moveTo(offset - 1, false);
this.moveTo(offset + 1, true);
this.deleteSelection();
} else if (doc.getElementAt(offset - 1).isEmpty()) {
// deleting the right sentinel of an empty element
// so just delete the whole element an move on
this.moveTo(offset - 2, true);
this.deleteSelection();
} else {
offset--;
if (doc.getCharacterAt(offset) != 0) {
this.moveTo(offset, false);
this.moveTo(offset + 1, true);
this.deleteSelection();
}
}
}
}
public void deleteSelection() {
try {
if (this.hasSelection()) {
this.document.delete(this.getSelectionStart(), this.getSelectionEnd());
this.moveTo(this.getSelectionStart());
}
} catch (DocumentValidationException ex) {
ex.printStackTrace(); // This should never happen, because we
// constrain the selection
}
}
public void doWork(Runnable runnable) {
this.doWork(false, runnable);
}
public void doWork(boolean savePosition, Runnable runnable) {
Position position = null;
if (savePosition) {
position = this.getDocument().createPosition(this.getCaretOffset());
}
boolean success = false;
try {
this.beginWork();
runnable.run();
success = true;
} catch (Exception ex) {
ex.printStackTrace();
} finally {
this.endWork(success);
if (position != null) {
this.moveTo(position.getOffset());
}
}
}
public void endWork(boolean success) {
this.beginWorkCount--;
if (this.beginWorkCount == 0) {
//this.compoundEdit.end();
if (success) {
this.undoList.add(
new UndoableAndOffset(
this.compoundEdit,
this.beginWorkCaretOffset));
this.undoDepth++;
if (undoList.size() > MAX_UNDO_STACK_SIZE) {
undoList.removeFirst();
}
this.redoList.clear();
this.relayout();
this.hostComponent.fireSelectionChanged();
} else {
try {
this.compoundEdit.undo();
this.moveTo(this.beginWorkCaretOffset);
} catch (CannotUndoException e) {
// TODO: handle exception
}
}
this.compoundEdit = null;
}
}
public Box findInnermostBox(IBoxFilter filter) {
return this.findInnermostBox(filter, this.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(IBoxFilter filter, int offset) {
Box box = this.rootBox.getChildren()[0];
Box matchingBox = null;
for (;;) {
if (filter.matches(box)) {
matchingBox = box;
}
Box original = box;
Box[] children = box.getChildren();
for (int i = 0; i < children.length; i++) {
Box child = children[i];
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() {
// XXX RAP auskommentiert cp
if (this.styleSheet == null)
{
return new Color(255, 255, 255);
}
return
this.styleSheet.getStyles(this.document.getRootElement()).getBackgroundColor();
// return new Color(255, 255, 255);
}
public BoxFactory getBoxFactory() {
return this.boxFactory;
}
/**
* Returns the current caret.
*/
public Caret getCaret() {
return this.caret;
}
public int getCaretOffset() {
return this.caretOffset;
}
public Element getCurrentElement() {
// XXX RAP auskommentiert cp
// System.out.println(this.currentElement);
return this.currentElement;
}
public Document getDocument() {
return this.document;
}
/**
* Returns the natural height of the widget based on the current layout
* width.
*/
public int getHeight() {
return this.rootBox.getHeight();
}
public String[] getValidInsertElements() {
Document doc = this.getDocument();
if (doc == null) {
return new String[0];
}
Validator validator = doc.getValidator();
if (validator == null) {
return new String[0];
}
int startOffset = this.getCaretOffset();
int endOffset = this.getCaretOffset();
if (this.hasSelection()) {
startOffset = this.getSelectionStart();
endOffset = this.getSelectionEnd();
}
Element parent = doc.getElementAt(startOffset);
String[] prefix =
doc.getNodeNames(parent.getStartOffset() + 1, startOffset);
String[] suffix = doc.getNodeNames(endOffset, parent.getEndOffset());
List candidates = new ArrayList();
candidates.addAll(
validator.getValidItems(parent.getName(), prefix, suffix));
candidates.remove(Validator.PCDATA);
// If there's a selection, root out those candidates that can't
// contain it.
if (this.hasSelection()) {
String[] selectedNodes = doc.getNodeNames(startOffset, endOffset);
for (Iterator iter = candidates.iterator(); iter.hasNext();) {
String candidate = (String) iter.next();
if (!validator
.isValidSequence(candidate, selectedNodes, true)) {
iter.remove();
}
}
}
Collections.sort(candidates);
return (String[]) candidates.toArray(new String[candidates.size()]);
}
/**
* Returns the value of the antiAliased flag.
*/
public boolean isAntiAliased() {
return this.antiAliased;
}
public boolean isDebugging() {
return debugging;
}
public String[] getValidMorphElements() {
Document doc = this.getDocument();
if (doc == null) {
return new String[0];
}
Validator validator = doc.getValidator();
if (validator == null) {
return new String[0];
}
Element element = doc.getElementAt(this.getCaretOffset());
Element parent = element.getParent();
if (parent == null) {
// can't morph the root
return new String[0];
}
String[] prefix =
doc.getNodeNames(
parent.getStartOffset() + 1,
element.getStartOffset());
String[] suffix =
doc.getNodeNames(element.getEndOffset() + 1, parent.getEndOffset());
List candidates = new ArrayList();
candidates.addAll(
validator.getValidItems(parent.getName(), prefix, suffix));
candidates.remove(Validator.PCDATA);
// root out those that can't contain the current content
String[] content =
doc.getNodeNames(
element.getStartOffset() + 1,
element.getEndOffset());
for (Iterator iter = candidates.iterator(); iter.hasNext();) {
String candidate = (String) iter.next();
if (!validator.isValidSequence(candidate, content, true)) {
iter.remove();
}
}
Collections.sort(candidates);
return (String[]) candidates.toArray(new String[candidates.size()]);
}
public int getSelectionEnd() {
return this.selectionEnd;
}
public int getSelectionStart() {
return this.selectionStart;
}
public DocumentFragment getSelectedFragment() {
if (this.hasSelection()) {
return this.document.getFragment(
this.getSelectionStart(),
this.getSelectionEnd());
} else {
return null;
}
}
public String getSelectedText() {
if (this.hasSelection()) {
return this.document.getText(
this.getSelectionStart(),
this.getSelectionEnd());
} else {
return "";
}
}
public StyleSheet getStyleSheet() {
return this.styleSheet;
}
public int getUndoDepth() {
return this.undoDepth;
}
public int getLayoutWidth() {
return this.layoutWidth;
}
public RootBox getRootBox(){
return this.rootBox;
}
public boolean hasSelection() {
return this.getSelectionStart() != this.getSelectionEnd();
}
public void insertChar(char c) throws DocumentValidationException {
if (this.hasSelection()) {
this.deleteSelection();
}
this.document.insertText(this.getCaretOffset(), Character.toString(c));
this.moveBy(+1);
}
public void insertFragment(DocumentFragment frag)
throws DocumentValidationException {
if (this.hasSelection()) {
this.deleteSelection();
}
this.document.insertFragment(this.getCaretOffset(), frag);
this.moveTo(this.getCaretOffset() + frag.getLength());
}
public void insertElement(Element element)
throws DocumentValidationException {
boolean success = false;
try {
this.beginWork();
DocumentFragment frag = null;
if (this.hasSelection()) {
frag = this.getSelectedFragment();
this.deleteSelection();
}
this.document.insertElement(this.getCaretOffset(), element);
this.moveTo(this.getCaretOffset() + 1);
if (frag != null) {
this.insertFragment(frag);
}
this.scrollCaretVisible();
success = true;
} finally {
this.endWork(success);
}
}
public void insertText(String text) throws DocumentValidationException {
if (this.hasSelection()) {
this.deleteSelection();
}
boolean success = false;
try {
this.beginWork();
int i = 0;
for (;;) {
int j = text.indexOf('\n', i);
if (j == -1) {
break;
}
this.document.insertText(this.getCaretOffset(), text.substring(i, j));
this.moveTo(this.getCaretOffset() + (j - i));
this.split();
i = j + 1;
}
if (i < text.length()) {
this.document.insertText(this.getCaretOffset(), text.substring(i));
this.moveTo(this.getCaretOffset() + (text.length() - i));
}
success = true;
} finally {
this.endWork(success);
}
}
public void morph(Element element) throws DocumentValidationException {
Document doc = this.getDocument();
int offset = this.getCaretOffset();
Element currentElement = doc.getElementAt(offset);
if (currentElement == doc.getRootElement()) {
throw new DocumentValidationException("Cannot morph the root element.");
}
boolean success = false;
try {
this.beginWork();
this.moveTo(currentElement.getStartOffset() + 1, false);
this.moveTo(currentElement.getEndOffset(), true);
DocumentFragment frag = this.getSelectedFragment();
this.deleteSelection();
this.moveBy(-1, false);
this.moveBy(2, true);
this.deleteSelection();
this.insertElement(element);
if (frag != null) {
this.insertFragment(frag);
}
this.moveTo(offset, false);
success = true;
} finally {
this.endWork(success);
}
}
public void moveBy(int distance) {
this.moveTo(this.getCaretOffset() + distance, false);
}
public void moveBy(int distance, boolean select) {
this.moveTo(this.getCaretOffset() + distance, select);
}
public void moveTo(int offset) {
this.moveTo(offset, false);
}
public void moveTo(int offset, boolean select) {
if (offset >= 1 && offset <= this.document.getLength() - 1) {
// if (offset >= 0 && offset <= this.document.getLength()) {
// repaint the selection area, if any
this.repaintCaret();
this.repaintRange(this.getSelectionStart(), this.getSelectionEnd());
Element oldElement = this.currentElement;
this.caretOffset = offset;
this.currentElement = this.document.getElementAt(offset);
if (select) {
this.selectionStart = Math.min(this.mark, this.caretOffset);
this.selectionEnd = Math.max(this.mark, this.caretOffset);
// move selectionStart and selectionEnd to make sure we don't
// select a partial element
Element commonElement =
this.document.findCommonElement(this.selectionStart, this.selectionEnd);
Element element = this.document.getElementAt(this.selectionStart);
while (element != commonElement) {
this.selectionStart = element.getStartOffset();
element = this.document.getElementAt(this.selectionStart);
}
element = this.document.getElementAt(this.selectionEnd);
while (element != commonElement) {
this.selectionEnd = element.getEndOffset() + 1;
element = this.document.getElementAt(this.selectionEnd);
}
} else {
this.mark = offset;
this.selectionStart = offset;
this.selectionEnd = offset;
}
if (this.beginWorkCount == 0) {
this.relayout();
}
Graphics g = this.hostComponent.createDefaultGraphics();
LayoutContext context = this.createLayoutContext(g);
this.caret = this.rootBox.getCaret(context, offset);
Element element = this.getCurrentElement();
if (element != oldElement) {
this.caretColor = Color.BLACK;
while (element != null) {
Color bgColor = this.styleSheet.getStyles(element).getBackgroundColor();
if (bgColor != null) {
int red = ~bgColor.getRed() & 0xff;
int green = ~bgColor.getGreen() & 0xff;
int blue = ~bgColor.getBlue() & 0xff;
this.caretColor = new Color(red, green, blue);
break;
}
element = element.getParent();
}
}
g.dispose();
this.magicX = -1;
this.scrollCaretVisible();
this.hostComponent.fireSelectionChanged();
this.caretVisible = true;
this.repaintRange(this.getSelectionStart(), this.getSelectionEnd());
//XXX cp
}
}
public void moveToLineEnd(boolean select) {
this.moveTo(this.rootBox.getLineEndOffset(this.getCaretOffset()), select);
}
public void moveToLineStart(boolean select) {
this.moveTo(this.rootBox.getLineStartOffset(this.getCaretOffset()), select);
}
public void moveToNextLine(boolean select) {
int x = this.magicX == -1 ? this.caret.getBounds().getX() : this.magicX;
Graphics g = this.hostComponent.createDefaultGraphics();
int offset = this.rootBox.getNextLineOffset(this.createLayoutContext(g), this.getCaretOffset(), x);
g.dispose();
this.moveTo(offset, select);
this.magicX = x;
}
public void moveToNextPage(boolean select) {
int x = this.magicX == -1 ? this.caret.getBounds().getX() : this.magicX;
int y = this.caret.getY() +
Math.round(this.hostComponent.getViewport().getHeight() * 0.9f);
this.moveTo(this.viewToModel(x, y), select);
this.magicX = x;
}
public void moveToNextWord(boolean select) {
Document doc = this.getDocument();
int n = doc.getLength() - 1;
int offset = this.getCaretOffset();
while (offset < n
&& !Character.isLetterOrDigit(doc.getCharacterAt(offset))) {
offset++;
}
while (offset < n
&& Character.isLetterOrDigit(doc.getCharacterAt(offset))) {
offset++;
}
this.moveTo(offset, select);
}
public void moveToPreviousLine(boolean select) {
int x = this.magicX == -1 ? this.caret.getBounds().getX() : this.magicX;
Graphics g = this.hostComponent.createDefaultGraphics();
int offset = this.rootBox.getPreviousLineOffset(this.createLayoutContext(g), this.getCaretOffset(), x);
g.dispose();
this.moveTo(offset, select);
this.magicX = x;
}
public void moveToPreviousPage(boolean select) {
int x = this.magicX == -1 ? this.caret.getBounds().getX() : this.magicX;
int y = this.caret.getY() -
Math.round(this.hostComponent.getViewport().getHeight() * 0.9f);
this.moveTo(this.viewToModel(x, y), select);
this.magicX = x;
}
public void moveToPreviousWord(boolean select) {
Document doc = this.getDocument();
int offset = this.getCaretOffset();
while (offset > 1
&& !Character.isLetterOrDigit(doc.getCharacterAt(offset - 1))) {
offset--;
}
while (offset > 1
&& Character.isLetterOrDigit(doc.getCharacterAt(offset - 1))) {
offset--;
}
this.moveTo(offset, select);
}
/**
* 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(Graphics g, int x, int y) {
if (this.rootBox == null) {
return;
}
LayoutContext context = this.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....
Rectangle rect = g.getClipBounds();
int oldHeight = this.rootBox.getHeight();
this.rootBox.layout(context, rect.getY(), rect.getY() + rect.getHeight());
if (this.rootBox.getHeight() != oldHeight) {
this.hostComponent.setPreferredSize(this.rootBox.getWidth(), this.rootBox.getHeight());
}
this.rootBox.paint(context, 0, 0);
if (this.caretVisible) {
// XXX RAP extra
if (this.caret == null)
{
caret = this.rootBox.getCaret(context, 0);
}
this.caret.draw(g, this.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();
*/
}
public void paste() throws DocumentValidationException {
throw new UnsupportedOperationException("Must be implemented in tookit-specific widget.");
}
public void pasteText() throws DocumentValidationException {
throw new UnsupportedOperationException("Must be implemented in tookit-specific widget.");
}
public void redo() {
if (redoList.size() == 0) {
throw new CannotRedoException();
}
UndoableAndOffset event = (UndoableAndOffset) redoList.removeLast();
this.moveTo(event.caretOffset, false);
event.edit.redo();
this.undoList.add(event);
undoDepth++;
}
public void removeAttribute(String attributeName) {
try {
Element element = this.getCurrentElement();
if (element.getAttribute(attributeName) != null) {
element.removeAttribute(attributeName);
}
} catch (DocumentValidationException ex) {
ex.printStackTrace(); // TODO: when can this happen?
}
}
public void savePosition(Runnable runnable) {
Position pos = this.getDocument().createPosition(this.getCaretOffset());
try {
runnable.run();
} finally {
this.moveTo(pos.getOffset());
}
}
public void selectAll() {
this.moveTo(1);
this.moveTo(this.getDocument().getLength() - 1, true);
}
public void selectWord() {
Document doc = this.getDocument();
int startOffset = this.getCaretOffset();
int endOffset = this.getCaretOffset();
while (startOffset > 1
&& Character.isLetterOrDigit(doc.getCharacterAt(startOffset - 1))) {
startOffset--;
}
int n = doc.getLength() - 1;
while (endOffset < n
&& Character.isLetterOrDigit(doc.getCharacterAt(endOffset))) {
endOffset++;
}
if (startOffset < endOffset) {
this.moveTo(startOffset, false);
this.moveTo(endOffset, true);
}
}
/**
* Sets the value of the antiAliased flag.
*
* @param antiAliased if true, text is rendered using antialiasing.
*/
public void setAntiAliased(boolean antiAliased) {
this.antiAliased = antiAliased;
}
public void setAttribute(String attributeName, String value) {
try {
Element element = this.getCurrentElement();
if (value == null) {
this.removeAttribute(attributeName);
} else if (!value.equals(element.getAttribute(attributeName))) {
element.setAttribute(attributeName, value);
}
} catch (DocumentValidationException ex) {
ex.printStackTrace(); // TODO: mebbe throw the exception instead
}
}
public void setBoxFactory(BoxFactory boxFactory) {
this.boxFactory = boxFactory;
if (this.document != null) {
this.relayout();
}
}
public void setDebugging(boolean debugging) {
this.debugging = debugging;
}
public void setDocument(Document document, StyleSheet styleSheet) {
if (this.document != null) {
this.document.removeDocumentListener(this.documentListener);
}
this.document = document;
this.styleSheet = styleSheet;
this.undoList = new LinkedList();
this.undoDepth = 0;
this.redoList = new LinkedList();
this.beginWorkCount = 0;
this.compoundEdit = null;
this.createRootBox();
this.moveTo(1);
this.document.addDocumentListener(this.documentListener);
}
public void setDocument(URL docUrl, URL ssURL)
throws IOException, ParserConfigurationException, SAXException {
StyleSheetReader ssReader = new StyleSheetReader();
final StyleSheet ss = ssReader.read(ssURL);
DocumentReader reader = new DocumentReader();
reader.setWhitespacePolicyFactory(new IWhitespacePolicyFactory() {
public IWhitespacePolicy getPolicy(String publicId) {
return new CssWhitespacePolicy(ss);
}
});
Document doc = reader.read(docUrl);
this.setDocument(doc, ss);
}
/**
* Called by the host component when it gains or loses focus.
* @param focus true if the host component has focus
*/
public void setFocus(boolean focus) {
this.caretVisible = true;
this.repaintCaret();
}
public void setLayoutWidth(int width) {
// System.out.println("vex wd impl setLayoutWidth " + width);
width = Math.max(width, MIN_LAYOUT_WIDTH);
if (this.getDocument() != null && width != this.getLayoutWidth()) {
// this.layoutWidth is set by relayoutAll
this.relayoutAll(width, this.styleSheet);
} else {
// maybe doc is null. Let's store layoutWidth so it's right
// when we set a doc
this.layoutWidth = width;
}
}
public void setStyleSheet(StyleSheet styleSheet) {
if (this.getDocument() != null) {
this.relayoutAll(this.layoutWidth, styleSheet);
}
}
public void setStyleSheet(URL ssUrl) throws IOException {
StyleSheetReader reader = new StyleSheetReader();
StyleSheet ss = reader.read(ssUrl);
this.setStyleSheet(ss);
}
public void split() throws DocumentValidationException {
long start = System.currentTimeMillis();
Document doc = this.getDocument();
Element element = doc.getElementAt(this.getCaretOffset());
Styles styles = this.getStyleSheet().getStyles(element);
while (!styles.isBlock()) {
element = element.getParent();
styles = this.getStyleSheet().getStyles(element);
}
boolean success = false;
try {
this.beginWork();
if (styles.getWhiteSpace().equals(CSS.PRE)) {
// can't call this.insertText() or we'll get an infinite loop
int offset = this.getCaretOffset();
doc.insertText(offset, "\n");
this.moveTo(offset + 1);
} else {
DocumentFragment frag = null;
int offset = this.getCaretOffset();
boolean atEnd = (offset == element.getEndOffset());
if (!atEnd) {
this.moveTo(element.getEndOffset(), true);
frag = this.getSelectedFragment();
this.deleteSelection();
}
// either way, we are now at the end offset for the element
// let's move just outside
this.moveTo(this.getCaretOffset() + 1);
this.insertElement(new Element(element.getName()));
// TODO: clone attributes
if (!atEnd) {
offset = this.getCaretOffset();
this.insertFragment(frag);
this.moveTo(offset, false);
}
}
success = true;
} finally {
this.endWork(success);
}
if (this.isDebugging()) {
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() {
this.caretVisible = !this.caretVisible;
this.repaintCaret();
}
public void undo() {
if (undoList.size() == 0) {
throw new CannotUndoException();
}
UndoableAndOffset event = (UndoableAndOffset) undoList.removeLast();
this.undoDepth--;
event.edit.undo();
this.moveTo(event.caretOffset, false);
this.redoList.add(event);
}
public int viewToModel(int x, int y) {
Graphics g = this.hostComponent.createDefaultGraphics();
LayoutContext context = this.createLayoutContext(g);
int offset = this.rootBox.viewToModel(context, x, y);
g.dispose();
return offset;
}
//================================================== PRIVATE
/**
* Captures an UndoableAction and the offset at which
* it occurred.
*/
private class UndoableAndOffset {
public IUndoableEdit edit;
public int caretOffset;
public UndoableAndOffset(IUndoableEdit edit, int caretOffset) {
this.edit = edit;
this.caretOffset = caretOffset;
}
}
/**
* 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(IUndoableEdit edit, int caretOffset) {
if (edit == null) {
return;
}
if (this.compoundEdit != null) {
this.compoundEdit.addEdit(edit);
} else {
if (this.undoList.size() > 0 &&
((UndoableAndOffset) this.undoList.getLast()).edit.combine(edit)) {
return;
} else {
this.undoList.add(new UndoableAndOffset(edit, caretOffset));
this.undoDepth++;
if (undoList.size() > MAX_UNDO_STACK_SIZE) {
undoList.removeFirst();
}
this.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(Graphics g) {
LayoutContext context = new LayoutContext();
context.setBoxFactory(this.getBoxFactory());
context.setDocument(this.getDocument());
context.setGraphics(g);
context.setStyleSheet(this.getStyleSheet());
if (this.hasSelection()) {
context.setSelectionStart(this.getSelectionStart());
context.setSelectionEnd(this.getSelectionEnd());
} else {
context.setSelectionStart(this.getCaretOffset());
context.setSelectionEnd(this.getCaretOffset());
}
return context;
}
private void createRootBox() {
Graphics g = this.hostComponent.createDefaultGraphics();
LayoutContext context = this.createLayoutContext(g);
context.setStyleSheet(styleSheet);
this.rootBox = new RootBox(context, this.document.getRootElement(), this.getLayoutWidth());
g.dispose();
}
/**
* Invalidates the box tree due to document changes. The lowest box that completely
* encloses the changed element is invalidated.
*
* @param element Element for which to search.
*/
private void invalidateElementBox(final Element element) {
BlockBox elementBox = (BlockBox) this.findInnermostBox(new IBoxFilter() {
public boolean matches(Box box) {
return box instanceof BlockBox
&& box.getElement() != null
&& box.getStartOffset() <= element.getStartOffset() + 1
&& box.getEndOffset() >= element.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(int offset) {
if (offset <= 1 || offset >= this.getDocument().getLength() - 1) {
return false;
}
Element e1 = this.getDocument().getElementAt(offset - 1);
Element e2 = this.getDocument().getElementAt(offset + 1);
return e1 != e2
&& e1.getParent() == e2.getParent()
&& e1.getName().equals(e2.getName());
}
/**
* 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(int offset) {
// System.out.println("vex wd impl iterateLayout " + offset);
int repaintStart = Integer.MAX_VALUE;
int repaintEnd = 0;
Graphics g = this.hostComponent.createDefaultGraphics();
LayoutContext context = this.createLayoutContext(g);
int layoutY = this.rootBox.getCaret(context, offset).getY();
while (true) {
int oldLayoutY = layoutY;
IntRange repaintRange = this.rootBox.layout(context, layoutY - LAYOUT_WINDOW/2, layoutY + LAYOUT_WINDOW/2);
if (repaintRange != null) {
repaintStart = Math.min(repaintStart, repaintRange.getStart());
repaintEnd = Math.max(repaintEnd, repaintRange.getEnd());
}
layoutY = this.rootBox.getCaret(context, offset).getY();
if (Math.abs(layoutY - oldLayoutY) < LAYOUT_TOLERANCE) {
break;
}
}
g.dispose();
if (repaintStart < repaintEnd) {
Rectangle viewport = this.hostComponent.getViewport();
if (repaintStart < viewport.getY() + viewport.getHeight() &&
repaintEnd > viewport.getY()) {
int start = Math.max(repaintStart, viewport.getY());
int end = Math.min(repaintEnd, viewport.getY() + viewport.getHeight());
this.hostComponent.repaint(viewport.getX(), start, viewport.getWidth(), end - start);
}
}
}
/**
* Joins the elements at the given offset. Only works if
* isBetweenMatchingElements returns true for the same offset.
* Afterwards, the caret is left at the point where the join occurred.
*
* @param offset Offset where the two elements meet.
*/
private void joinElementsAt(int offset)
throws DocumentValidationException {
if (!isBetweenMatchingElements(offset)) {
throw new DocumentValidationException(
"Cannot join elements at offset " + offset);
}
boolean success = false;
try {
this.beginWork();
this.moveTo(offset + 1);
Element element = this.getCurrentElement();
boolean moveContent = !element.isEmpty();
DocumentFragment frag = null;
if (moveContent) {
this.moveTo(element.getEndOffset(), true);
frag = this.getSelectedFragment();
this.deleteSelection();
}
this.moveBy(-1);
this.moveBy(2, true);
this.deleteSelection();
this.moveBy(-1);
if (moveContent) {
int savedOffset = this.getCaretOffset();
this.insertFragment(frag);
this.moveTo(savedOffset, false);
}
success = true;
} finally {
this.endWork(success);
}
}
/**
* Lay out the area around the caret.
*/
public void relayout() {
// System.out.println("vex wd impl relayout");
long start = System.currentTimeMillis();
int oldHeight = this.rootBox.getHeight();
this.iterateLayout(this.getCaretOffset());
if (this.rootBox.getHeight() != oldHeight) {
this.hostComponent.setPreferredSize(this.rootBox.getWidth(), this.rootBox.getHeight());
}
Graphics g = this.hostComponent.createDefaultGraphics();
LayoutContext context = this.createLayoutContext(g);
this.caret = this.rootBox.getCaret(context, this.getCaretOffset());
g.dispose();
if (this.isDebugging()) {
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(int newWidth, StyleSheet newStyleSheet) {
// System.out.println("vex widget impl relayoutAll");
Graphics g = this.hostComponent.createDefaultGraphics();
LayoutContext context = this.createLayoutContext(g);
Rectangle viewport = this.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).
boolean caretVisible = viewport.intersects(this.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 = this.caret.getY() - viewport.getY();
offset = this.getCaretOffset();
} else {
offset = this.rootBox.viewToModel(context, 0, viewport.getY());
}
this.layoutWidth = newWidth;
this.styleSheet = newStyleSheet;
// Re-create the context, since it holds the old stylesheet
context = this.createLayoutContext(g);
this.createRootBox();
this.iterateLayout(offset);
this.hostComponent.setPreferredSize(this.rootBox.getWidth(), this.rootBox.getHeight());
this.caret = this.rootBox.getCaret(context, this.getCaretOffset());
if (caretVisible) {
int viewportY = this.caret.getY() - Math.min(relCaretY, viewport.getHeight());
viewportY = Math.min(this.rootBox.getHeight() - viewport.getHeight(), viewportY);
viewportY = Math.max(0, viewportY); // this must appear after the above line, since
// that line might set viewportY negative
this.hostComponent.scrollTo(viewport.getX(), viewportY);
this.scrollCaretVisible();
} else {
int viewportY = this.rootBox.getCaret(context, offset).getY();
this.hostComponent.scrollTo(viewport.getX(), viewportY);
}
this.hostComponent.repaint();
g.dispose();
}
/**
* Repaints the area of the caret.
*/
private void repaintCaret() {
if (this.caret != null) {
// caret may be null when document is first set
Rectangle bounds = this.caret.getBounds();
// XXX RAP extra if-clause
if (!this.hostComponent.isDisposed())
{
this.hostComponent.repaint(bounds.getX(), bounds.getY(), bounds.getWidth(), bounds.getHeight());
}
}
}
/**
* Repaints area of the control corresponding to a range of
* offsets in the document.
*
* @param startOffset Starting offset of the range.
* @param endOffset Ending offset of the range.
*/
private void repaintRange(int startOffset, int endOffset) {
// System.out.println("vex wd impl repaintRange " + startOffset + " end " + endOffset);
Graphics g = this.hostComponent.createDefaultGraphics();
LayoutContext context = this.createLayoutContext(g);
Rectangle startBounds = this.rootBox.getCaret(context, startOffset).getBounds();
int top1 = startBounds.getY();
int bottom1 = top1 + startBounds.getHeight();
Rectangle endBounds = this.rootBox.getCaret(context, endOffset).getBounds();
int top2 = endBounds.getY();
int bottom2 = top2 + endBounds.getHeight();
int top = Math.min(top1, top2);
int bottom = Math.max(bottom1, bottom2);
// XXX RAP auskommentiert cp
if (top == bottom)
{
// Account for zero-height horizontal carets
this.hostComponent.repaint(0, top - 1, this.getLayoutWidth(), bottom - top + 1);
}
else
{
this.hostComponent.repaint(0, top, this.getLayoutWidth(), bottom - top);
}
g.dispose();
}
// private void workaroundSetVisible() {
//
//
// this.hostComponent.scrollTo(0, -2);
// }
private void scrollCaretVisible() {
Rectangle caretBounds = this.caret.getBounds();
Rectangle viewport = this.hostComponent.getViewport();
int x = viewport.getX();
int y = 0;
int offset = getCaretOffset();
if (offset == 1) {
y = 0;
} else if (offset == getDocument().getLength() - 1) {
if (this.rootBox.getHeight() < viewport.getHeight()) {
y = 0;
} else {
y = this.rootBox.getHeight() - viewport.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;
}
//XXX workaround cp
// System.out.println("scrollCaretVisible " + x + " y " + y);
if (y == 0)
{
y = -2;
}
//XXX end workaround cp
this.hostComponent.scrollTo(x, y);
}
}