From 0b0d43d557cc8fb1fefdbca7a949aff7f9b93cc4 Mon Sep 17 00:00:00 2001 From: Paul Pazderski Date: Thu, 28 Feb 2019 22:51:55 +0100 Subject: Bug 544970 - [console] Fix ConsoleDocumentAdapter's TextChangingEvent calculation The existing implementation produces wrong or incomplete TextChangingEvents in various situations especially in fixed width mode which lead to exceptions or wrong rendering of console content e.g. - inserting content in fixed width mode lead to exceptions in many cases most likely by holding down a key until the input hit the fixed width border - lines after changed line are not updated and still show outdated content - removing line breaks (either due to wrapping or delimiter character) lead to exceptions - trailing wrapped line content was not correctly repainted Some of these errors leave the console in an dysfunctional state where even moving the cursor can produce more exceptions. Additional improvement: while the old implementation's interpretation of line delimiter was based on what java.util.regex.Pattern thinks a line delimiter is, the new implementation use the legal line delimiters of the connected document. Change-Id: I3283ebeeef2e006357da1b1432bf1d24ab78475c Signed-off-by: Paul Pazderski --- .../internal/console/ConsoleDocumentAdapter.java | 770 +++++++++++++++------ 1 file changed, 569 insertions(+), 201 deletions(-) diff --git a/org.eclipse.ui.console/src/org/eclipse/ui/internal/console/ConsoleDocumentAdapter.java b/org.eclipse.ui.console/src/org/eclipse/ui/internal/console/ConsoleDocumentAdapter.java index f8f0df6f5..64b4616a5 100644 --- a/org.eclipse.ui.console/src/org/eclipse/ui/internal/console/ConsoleDocumentAdapter.java +++ b/org.eclipse.ui.console/src/org/eclipse/ui/internal/console/ConsoleDocumentAdapter.java @@ -10,13 +10,13 @@ * * Contributors: * IBM Corporation - initial API and implementation + * Paul Pazderski - Bug 544970: reimplementation with correct text change event calculation *******************************************************************************/ package org.eclipse.ui.internal.console; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import org.eclipse.core.runtime.Assert; import org.eclipse.jface.text.BadLocationException; @@ -24,105 +24,122 @@ import org.eclipse.jface.text.DocumentEvent; import org.eclipse.jface.text.IDocument; import org.eclipse.jface.text.IDocumentAdapter; import org.eclipse.jface.text.IDocumentListener; +import org.eclipse.jface.text.IRegion; +import org.eclipse.jface.text.Region; +import org.eclipse.jface.text.TextUtilities; import org.eclipse.swt.custom.TextChangeListener; import org.eclipse.swt.custom.TextChangedEvent; import org.eclipse.swt.custom.TextChangingEvent; +import org.eclipse.ui.console.ConsolePlugin; /** - * Adapts a Console's document to the viewer StyledText widget. Allows proper line - * wrapping of fixed width consoles without having to add line delimiters to the StyledText. + * Adapts a Console's document to the viewer StyledText widget. Allows proper + * line wrapping of fixed width consoles without having to add line delimiters + * to the StyledText. * - * By using this adapter, the offset of any character is the same in both the widget and the - * document. + * By using this adapter, the offset of any character is the same in both the + * widget and the document. + * + *

+ * Note: tab character (\t) is not handled special and counts as one character. + * So wrapped content may look less fixed width if it contains tab characters. + *

* * @since 3.1 */ public class ConsoleDocumentAdapter implements IDocumentAdapter, IDocumentListener { + /* + * Because of the fixed width feature this adapter knows two types of lines. + * Document lines are lines as known to the adapted document and are terminated + * by a line delimiter (except for the last line). Widget lines are lines as + * seen in the text viewer. If a document line is wrapped due to fixed width it + * starts a new widget line. Widget lines generated by line wrapping do not have + * additional line delimiters. If fixed width is disabled widget line and + * document line are the same. + */ - private int consoleWidth = -1; - private List textChangeListeners; - private IDocument document; - - int[] offsets = new int[5000]; - int[] lengths = new int[5000]; - private int regionCount = 1; - private Pattern pattern = Pattern.compile("$", Pattern.MULTILINE); //$NON-NLS-1$ - - - public ConsoleDocumentAdapter(int width) { - textChangeListeners = new ArrayList<>(); - consoleWidth = width; - } + /** + * If true the adapter will check if the predicted document change + * calculated for the TextChangingEvent matches the document after the applied + * change. Useful for developing/debugging. + */ + private static final boolean ASSERT = false; - /* - * repairs lines list from the beginning of the line containing the offset of any - * DocumentEvent, to the end of the Document. + /** + * If {@link #widgetLineOffsets} need to grow add some extra space to prevent + * frequent array copies. */ - private void repairLines(int eventOffset) { - if (document == null) { - return; - } - try { - int docLine = document.getLineOfOffset(eventOffset); - int docLineOffset = document.getLineOffset(docLine); - int widgetLine = getLineAtOffset(docLineOffset); + private static final int GROW = 500; - for (int i=regionCount-1; i>=widgetLine; i--) { - regionCount--; - } + /** Registered {@link TextChangeListener}s. */ + private final List textChangeListeners = new ArrayList<>(); - int numLinesInDoc = document.getNumberOfLines(); + /** Fixed console width. If <= 0 fixed console mode is disabled. */ + private int fixedConsoleWidth; - int nextOffset = document.getLineOffset(docLine); - for (int i = docLine; i + * This is usually { "\r", "\n", "\r\n" }. + *

+ */ + private String[] docLegalLineDelimiters; - if (length == 0) { - addRegion(offset, 0); - } else { - while (length > 0) { - int trimmedLength = length; - String lineDelimiter = document.getLineDelimiter(i); - int lineDelimiterLength = 0; - if (lineDelimiter != null) { - lineDelimiterLength = lineDelimiter.length(); - trimmedLength -= lineDelimiterLength; - } - - if (consoleWidth > 0 && consoleWidth < trimmedLength) { - addRegion(offset, consoleWidth); - offset += consoleWidth; - length -= consoleWidth; - } else { - addRegion(offset, length); - offset += length; - length -= length; - } - } - } - } - } catch (BadLocationException e) { - } + /** + * Number of widget lines in document. If fixed width is disabled it is always + * equal to {@link IDocument#getNumberOfLines()}. If fixed width mode is enabled + * there may be more widget lines then doc lines but never less. + */ + private int widgetLines; - if (regionCount == 0) { - addRegion(0, document.getLength()); - } - } + /** + * Start offsets of widget lines. + *

+ * Note: offsets are only valid and updated if fixed width is enabled. + *

+ *

+ * Example content: if the document contains the following content (with + * \r\n as line delimiter) + * + *

+	 *     0123456789ABCD\r\n
+	 *     ---\r\n
+	 *     0123456789
+	 * 
+ * + * a fixed width of 10 the widget will show it as + * + *
+	 *     0123456789
+	 *     ABCD\r\n
+	 *     ---\r\n
+	 *     0123456789
+	 * 
+ * + * the content of the {@link #widgetLineOffsets} array is: + * { 0, 10, 16, 21 } which represent the start offsets from each of + * the four widget lines. + *

+ *

+ * Initialized with one element to save memory in non fixed width mode but at + * the same time prevent NPE. + *

+ * + * @see #isFixedWidth() + * @see #ensureOffsetsCapacity(int) + */ + private int[] widgetLineOffsets = new int[1]; - private void addRegion(int offset, int length) { - if (regionCount == 0) { - offsets[0] = offset; - lengths[0] = length; - } else { - if (regionCount == offsets.length) { - growRegionArray(regionCount * 2); - } - offsets[regionCount] = offset; - lengths[regionCount] = length; - } - regionCount++; + /** + * New {@link ConsoleDocumentAdapter} with no {@link IDocument} connected yet. + * + * @param width fixed console width to enforce text wrap or <= 0 to disable + * fixed console width + */ + public ConsoleDocumentAdapter(int width) { + setWidth(width); } @Override @@ -132,16 +149,18 @@ public class ConsoleDocumentAdapter implements IDocumentAdapter, IDocumentListen } document = doc; + docLegalLineDelimiters = null; + updateWidgetOffsets(0); - if (document != null) { - document.addDocumentListener(this); - repairLines(0); + if (doc != null) { + doc.addDocumentListener(this); + docLegalLineDelimiters = doc.getLegalLineDelimiters(); } } @Override public synchronized void addTextChangeListener(TextChangeListener listener) { - Assert.isNotNull(listener); + Assert.isLegal(listener != null, "listener null"); //$NON-NLS-1$ if (!textChangeListeners.contains(listener)) { textChangeListeners.add(listener); } @@ -149,10 +168,8 @@ public class ConsoleDocumentAdapter implements IDocumentAdapter, IDocumentListen @Override public synchronized void removeTextChangeListener(TextChangeListener listener) { - if(textChangeListeners != null) { - Assert.isNotNull(listener); - textChangeListeners.remove(listener); - } + Assert.isLegal(listener != null, "listener null"); //$NON-NLS-1$ + textChangeListeners.remove(listener); } @Override @@ -163,62 +180,134 @@ public class ConsoleDocumentAdapter implements IDocumentAdapter, IDocumentListen @Override public String getLine(int lineIndex) { try { - StringBuffer line = new StringBuffer(document.get(offsets[lineIndex], lengths[lineIndex])); - int index = line.length() - 1; - while(index > -1 && (line.charAt(index)=='\n' || line.charAt(index)=='\r')) { - index--; - } - return new String(line.substring(0, index+1)); + final IRegion lineRegion = getLineInformation(lineIndex); + return document.get(lineRegion.getOffset(), lineRegion.getLength()); } catch (BadLocationException e) { + log(e); + return ""; //$NON-NLS-1$ } - return ""; //$NON-NLS-1$ } - @Override - public int getLineAtOffset(int offset) { - if (offset == 0 || regionCount <= 1) { - return 0; + /** + * Return information about a widget line. + *

+ * Note: in contrast to some implementations of + * {@link org.eclipse.jface.text.ILineTracker#getLineInformation(int)} this + * implementation throws an exception if line after last line is queried. + *

+ * + * @param widgetLineIndex the widget line number (first line has number 0) + * @return the lines start offset and length excluding line delimiter + * @throws BadLocationException if widget line does not exist + * @see IDocument#getLineInformation(int) + */ + private IRegion getLineInformation(int widgetLineIndex) throws BadLocationException { + if (!isFixedWidth()) { + return document.getLineInformation(widgetLineIndex); } - if (offset == document.getLength()) { - return regionCount-1; - } + final int widgetLineOffset = getLineOffset(widgetLineIndex); + final IRegion docLine = document.getLineInformationOfOffset(widgetLineOffset); + // widget line length is calculated as: length of containing document line + int widgetLineLength = docLine.getLength(); + // minus preceding wrapped lines + widgetLineLength -= (widgetLineOffset - docLine.getOffset()); + // now widgetLinelength is length from widget line start offset until real + // document line end. If widgetLinelength is still greater than + // fixedConsoleWidth there are more wrapped content after this widget line + // therefore this widget line length must equal fixedConsoleWidth otherwise it + // is the length of requested widget line. + widgetLineLength = Math.min(widgetLineLength, fixedConsoleWidth); + return new Region(widgetLineOffset, widgetLineLength); + } - int left= 0; - int right= regionCount-1; - int midIndex = 0; + @Override + public int getLineAtOffset(int offset) { + try { + return getLineOfOffset(offset); + } catch (BadLocationException e) { + log(e); + // Note: this strange return value is inherited from the previous implementation + // of this method which did no boundary checks on the offset and either returned + // the first or last line index for bad locations. + return offset < 0 ? 0 : widgetLines - 1; + } + } - while (left <= right) { - if(left == right) { - return right; + /** + * Return the widget line index at the given character offset. + *

+ * Like {@link #getLineAtOffset(int)} but throws exception in case of invalid + * offset. + *

+ * + * @param offset offset in document + * @return the number of the widget line at offset (0 for first line) + * @throws BadLocationException if the offset is invalid in adapted document + * @see #getLineAtOffset(int) + * @see IDocument#getLineOfOffset(int) + */ + private int getLineOfOffset(int offset) throws BadLocationException { + if (!isFixedWidth()) { + return document.getLineOfOffset(offset); + } else { + if (offset < 0 || offset > getCharCount()) { + throw new BadLocationException(offset + " is not a valid offset."); //$NON-NLS-1$ } - midIndex = (left + right) / 2; - - if (offset < offsets[midIndex]) { - right = midIndex; - } else if (offset >= offsets[midIndex] + lengths[midIndex]) { - left = midIndex + 1; - } else { - return midIndex; + int widgetLine = Arrays.binarySearch(widgetLineOffsets, 0, widgetLines, offset); + if (widgetLine < 0) { + // no exact offset match. At this point (-widgetLine) - 1 can be used as + // insertion point to insert the searched offset in the array. Since we do not + // want to insert but request the line containing the offset the desired answer + // is the index before the insertion index. + widgetLine = (-widgetLine) - 2; } + return widgetLine; } - - return midIndex; } @Override public int getLineCount() { - return regionCount; + return widgetLines; } @Override public String getLineDelimiter() { - return System.getProperty("line.separator"); //$NON-NLS-1$ + return System.lineSeparator(); } @Override public int getOffsetAtLine(int lineIndex) { - return offsets[lineIndex]; + try { + return getLineOffset(lineIndex); + } catch (BadLocationException e) { + log(e); + return -1; + } + } + + /** + * Return the character offset of the first character of the given line. + *

+ * Like {@link #getOffsetAtLine(int)} but throws exception in case of invalid + * line numbers. + *

+ * + * @param widgetLineIndex the widget line number (first line has number 0) + * @return offset of the first character of the line + * @throws BadLocationException if widget line does not exist + * @see #getOffsetAtLine(int) + * @see IDocument#getLineOffset(int) + */ + private int getLineOffset(int widgetLineIndex) throws BadLocationException { + if (!isFixedWidth()) { + return document.getLineOffset(widgetLineIndex); + } else { + if (widgetLineIndex < 0 || widgetLineIndex >= widgetLines) { + throw new BadLocationException(widgetLineIndex + " is not a valid line index."); //$NON-NLS-1$ + } + return widgetLineOffsets[widgetLineIndex]; + } } @Override @@ -226,8 +315,9 @@ public class ConsoleDocumentAdapter implements IDocumentAdapter, IDocumentListen try { return document.get(start, length); } catch (BadLocationException e) { + log(e); + return null; } - return null; } @Override @@ -235,6 +325,7 @@ public class ConsoleDocumentAdapter implements IDocumentAdapter, IDocumentListen try { document.replace(start, replaceLength, text); } catch (BadLocationException e) { + log(e); } } @@ -249,121 +340,398 @@ public class ConsoleDocumentAdapter implements IDocumentAdapter, IDocumentListen @Override public synchronized void documentAboutToBeChanged(DocumentEvent event) { - if (document == null) { - return; + TextChangingEvent changingEvent; + if (!isFixedWidth()) { + changingEvent = new TextChangingEvent(this); + changingEvent.start = event.getOffset(); + changingEvent.newText = event.getText() == null ? "" : event.getText(); //$NON-NLS-1$ + changingEvent.replaceCharCount = event.getLength(); + changingEvent.newCharCount = changingEvent.newText.length(); + try { + changingEvent.replaceLineCount = document.getNumberOfLines(event.getOffset(), event.getLength()) - 1; + } catch (BadLocationException e) { + log(e); + } + changingEvent.newLineCount = document.computeNumberOfLines(changingEvent.newText); + } else { + try { + changingEvent = generateTextChangingEvent(event); + } catch (BadLocationException e) { + log(e); + // Should never happen. + // But provide empty changing event to hopefully reduce damage. + changingEvent = new TextChangingEvent(this); + } } - TextChangingEvent changeEvent = new TextChangingEvent(this); - changeEvent.start = event.fOffset; - changeEvent.newText = (event.fText == null ? "" : event.fText); //$NON-NLS-1$ - changeEvent.replaceCharCount = event.fLength; - changeEvent.newCharCount = (event.fText == null ? 0 : event.fText.length()); + for (TextChangeListener listener : textChangeListeners) { + listener.textChanging(changingEvent); + } - int first = getLineAtOffset(event.fOffset); - int lOffset = Math.max(event.fOffset + event.fLength - 1, 0); - int last = getLineAtOffset(lOffset); - changeEvent.replaceLineCount = Math.max(last - first, 0); + if (ASSERT) { + updatePrediction(changingEvent); + } + } - int newLineCount = countNewLines(event.fText); - changeEvent.newLineCount = newLineCount >= 0 ? newLineCount : 0; + /** + * Generate the {@link TextChangingEvent} for fixed width mode. Due to + * the dynamic wrapping of lines and the lack of line delimiters it is much more + * complicated than without fixed width. + * + * @param event the document change event + * @return the text changing event for the widget + * @throws BadLocationException if document event is invalid + */ + private TextChangingEvent generateTextChangingEvent(DocumentEvent event) throws BadLocationException { + String newText = event.getText() == null ? "" : event.getText(); //$NON-NLS-1$ + int newTextLength = newText.length(); + int eventOffset = event.getOffset(); + int eventLength = event.getLength(); + int replaceLineCount = 0; + int newLineCount = 0; + + // The primary duty of this method is to calculate new and removed line which + // includes the changing in line wrapping. + // The number of new and replaced lines must include any line whose line content + // is changing. Normally this only includes lines directly affected by the + // change event. But due to our magic line wrapping, as an extreme case, a + // single character inserted at first offset can, from StyledText-Widgets + // perspective, change the content of every line. + + if (newTextLength >= 0 || eventLength > 0) { + // In this method first and last refer to the first and last line affected by + // the current document event + final int firstDocLineIndex = document.getLineOfOffset(eventOffset); + final int firstDocLineOffset = document.getLineOffset(firstDocLineIndex); + + if (eventOffset != firstDocLineOffset && (eventOffset - firstDocLineOffset) % fixedConsoleWidth == 0) { + // event start is at fixed width border + // XXX: the trick here is important to do the impossible + // The fact that wrapped lines are new lines without a newline delimiter leads + // to some (nearly) impossible edge cases since all involved interfaces + // (implicit) assume a newline has its unique offset. + // + // Consider a fixed with of 10 and a console filled with (automatically wrapped) + // content of: + // 0123456789 + // 0 + // If we remove the last character ('0') we must set replaceLineCount to 1 since + // there is one line less due to the unwrapped line. The replaceLineCount is + // necessary so that StyledTextRenderer updates the lines which possible follow + // below and have changed due to moved content. + // But StyledTextRenderer presumes that the line index where the event occurs is + // the same before and after the text change. This is not the case for our auto + // wrapped lines since getLineAtOffset(10) is 1 before text change and 0 after. + // + // To solve this unlucky situation we simply never send text change event + // occurring at the fixed width wrap border. If such an document change happens + // we expand the text change to include also the character before the wrap + // border. + eventOffset--; + eventLength++; + newText = document.get(eventOffset, 1) + newText; + newTextLength++; + } - if (changeEvent.newLineCount > offsets.length-regionCount) { - growRegionArray(changeEvent.newLineCount); - } + final int eventEnd = eventOffset + eventLength; + final int firstWidgetLineIndex = getLineOfOffset(eventOffset); + final int firstWidgetLineOffset = getLineOffset(firstWidgetLineIndex); - for (TextChangeListener listener : textChangeListeners) { - listener.textChanging(changeEvent); + final int lastInsertLength; + int lastDocLineLengthDiff = -eventLength; + + int newTextOffset = 0; + int[] result = TextUtilities.indexOf(docLegalLineDelimiters, newText, newTextOffset); + if (result[1] < 0) { + // single line insert + lastInsertLength = eventOffset - firstWidgetLineOffset + newTextLength; + lastDocLineLengthDiff += newTextLength; + } else { + // multiline insert + // processed in three parts: + // 1. First line: everything from start of inserted text to (including) first + // line delimiter + // 2. Middle lines: everything from first line delimiter (exclusive) to last + // line delimiter (inclusive) (may contain additional delimiters) + // 3. Last line: everything (including) last line delimiter to end of inserted + // text + + final int firstInsertLength = result[0]; + // newLineCount here is numbers of lines required if text is wrapped -1 because + // we start inserting in an existing line and +1 for the first line delimiter we + // had found + newLineCount = linesIfWrapped(eventOffset - firstWidgetLineOffset + firstInsertLength); + + while (true) { + newTextOffset = result[0] + docLegalLineDelimiters[result[1]].length(); + result = TextUtilities.indexOf(docLegalLineDelimiters, newText, newTextOffset); + if (result[1] < 0) { + break; + } + final int insertedLineLength = result[0] - newTextOffset; + // new text's middle lines are unaffected from existing content and simply count + // as number of lines they need with wrapping + newLineCount += linesIfWrapped(insertedLineLength); + } + + final int lastTextPartLength = newTextLength - newTextOffset; + lastInsertLength = lastTextPartLength; + lastDocLineLengthDiff += lastTextPartLength; + lastDocLineLengthDiff -= eventOffset - firstWidgetLineOffset; + } + + final int lastDocLineIndex = document.getLineOfOffset(eventEnd); + final IRegion lastDocLine = document.getLineInformation(lastDocLineIndex); + + int affectedContentAfterRange = 0; + if (lastDocLineLengthDiff != 0) { + // the content after actual event range moved due to wrapping and must be + // reported as replaced lines to StyledTextRenderer to update lines rendering + affectedContentAfterRange = lastDocLine.getOffset() + lastDocLine.getLength() - eventEnd; + } + + // newLineCount here is numbers of lines required if text is wrapped -1 because + // we are prepending an existing line + // (and no real line delimiter left to consider) + newLineCount += linesIfWrapped(lastInsertLength + affectedContentAfterRange) - 1; + + // replaceLineCount is basically the difference between the index of the last + // affected line (line at event offset + replace length) and the index of the + // first affected line (line at event offset) + // There is one special case caused by the wrapping without delimiter approach. + // If the replace (a real replace event at this point, removing as many + // characters as inserting) is ending at a virtual wrap border the + // lastAffectedOffset would indicate the next line after wrap is affected even + // if this line isn't really affected. + final int lastAffectedOffset = eventEnd + affectedContentAfterRange; + int lastAffectedWidgetLineIndex = getLineOfOffset(lastAffectedOffset); + final int lastAffectedWidgetLineOffset = getLineOffset(lastAffectedWidgetLineIndex); + if (lastAffectedWidgetLineOffset == lastAffectedOffset && lastDocLine.getOffset() != lastAffectedOffset) { + lastAffectedWidgetLineIndex--; + } + replaceLineCount = lastAffectedWidgetLineIndex - firstWidgetLineIndex; } + + final TextChangingEvent changingEvent = new TextChangingEvent(this); + changingEvent.start = eventOffset; + changingEvent.newText = newText; + changingEvent.replaceCharCount = eventLength; + changingEvent.newCharCount = newTextLength; + changingEvent.replaceLineCount = replaceLineCount; + changingEvent.newLineCount = newLineCount; + return changingEvent; } - private void growRegionArray(int minSize) { - int size = Math.max(offsets.length*2, minSize*2); - int[] newOffsets = new int[size]; - System.arraycopy(offsets, 0, newOffsets, 0, regionCount); - offsets = newOffsets; - int[] newLengths = new int[size]; - System.arraycopy(lengths, 0, newLengths, 0, regionCount); - lengths = newLengths; + /** + * For the given text length calculate number of wrapped lines this text + * requires with current {@link #fixedConsoleWidth} setting. + *

+ * A line ending at the fixed length border will not result in an extra line, + * i.e. a line with length of consoleWidth results in one line. + *

+ *

+ * Line delimiters are ignored and count like any other character. Since line + * delimiters should not cause line wraps this method is intended to be called + * with line length excluding line delimiter. + *

+ * + * @param lineLength line length to calculate + * @return number of lines if given length is wrapped with current + * {@link #fixedConsoleWidth} (always >= 1) + */ + private int linesIfWrapped(int lineLength) { + if (fixedConsoleWidth <= 0 || lineLength <= 0) { + return 1; + } + return ((lineLength - 1) / fixedConsoleWidth) + 1; } - private int countNewLines(String string) { - int count = 0; + @Override + public synchronized void documentChanged(DocumentEvent event) { - if (string.length() == 0) { - return 0; - } + updateWidgetOffsets(event.getOffset()); - // work around to - // http://bugs.sun.com/bugdatabase/view_bug.do?bug_id=4994840 - // see bug 84641 - int offset = string.length() - 1; - while (offset > -1 && string.charAt(offset) == '\r') { - offset--; - count++; + TextChangedEvent changeEvent = new TextChangedEvent(this); + for (TextChangeListener listener : textChangeListeners) { + listener.textChanged(changeEvent); } - // if offset == -1, the line was all '\r' and there is no string to search for matches (bug 207743) - if (offset > -1) { - String str = string; - if (offset < (str.length() - 1)) { - str = str.substring(0, offset); - } - - int lastIndex = 0; - int index = 0; - Matcher matcher = pattern.matcher(str); + if (ASSERT) { + verifyPrediction(); + } + } - while (matcher.find()) { - index = matcher.start(); + /** + * Update list of widget line offsets. + *

+ * Note: the widget line offset lookup is only used for fixed width + * console and therefore only updated if fixed width is enabled. + *

+ * + * @param fromOffset all offsets before given offset are considered valid. Only + * update widget offsets from this offset onwards. + * @see #widgetLines + * @see #widgetLineOffsets + */ + private void updateWidgetOffsets(int fromOffset) { + if (document == null) { + widgetLines = 0; + return; + } - if (index == 0) { - count++; - } else if (index != str.length()) { - count++; + final int docLines = document.getNumberOfLines(); + if (!isFixedWidth()) { + widgetLines = docLines; + } else { + try { + final int offset = Math.max(fromOffset, 0); + // if someone manages to set the documents text to null the document may return + // a negative line number + int docLineIndex = Math.max(document.getLineOfOffset(offset), 0); + final int docLineOffset = document.getLineOffset(docLineIndex); + int widgetLineIndex; + if (docLineOffset > 0) { + widgetLineIndex = getLineOfOffset(docLineOffset); + } else { + widgetLineIndex = 0; + setLookupEntry(widgetLineIndex, 0); } - if (consoleWidth > 0) { - int lineLen = index - lastIndex + 1; - if (index == 0) { - lineLen += lengths[regionCount-1]; + for (; docLineIndex < docLines; docLineIndex++) { + int lineLength = document.getLineInformation(docLineIndex).getLength(); + for (; lineLength > fixedConsoleWidth; lineLength -= fixedConsoleWidth) { + widgetLineIndex++; + setLookupEntry(widgetLineIndex, widgetLineOffsets[widgetLineIndex - 1] + fixedConsoleWidth); } - count += lineLen/consoleWidth; - } - lastIndex = index; + final String docLineDelimiter = document.getLineDelimiter(docLineIndex); + if (docLineDelimiter != null) { + widgetLineIndex++; + setLookupEntry(widgetLineIndex, + widgetLineOffsets[widgetLineIndex - 1] + lineLength + docLineDelimiter.length()); + } + } + widgetLines = widgetLineIndex + 1; + } catch (BadLocationException e) { + // should be impossible if document is not changed meanwhile + log(e); } } - return count; } + /** + * Set an entry in the {@link #widgetLineOffsets} lookup. Will grow the lookup + * array if necessary. + * + * @param widgetLineIndex the lookup entry to set (does not have to exist yet) + * @param value the entry value to set + */ + private void setLookupEntry(int widgetLineIndex, int value) { + ensureOffsetsCapacity(widgetLineIndex + 1); + widgetLineOffsets[widgetLineIndex] = value; + } - @Override - public synchronized void documentChanged(DocumentEvent event) { - if (document == null) { - return; + /** + * Ensure the offset lookup array can store at least size entries. + * + * @param requiredSize minimum size lookup array should have after + * @see #widgetLineOffsets + */ + private void ensureOffsetsCapacity(int requiredSize) { + final int oldSize = widgetLineOffsets != null ? widgetLineOffsets.length : 0; + if (oldSize < requiredSize) { + final int[] oldWidgetOffsets = widgetLineOffsets; + widgetLineOffsets = new int[requiredSize + GROW]; + if (oldSize > 0) { + System.arraycopy(oldWidgetOffsets, 0, widgetLineOffsets, 0, oldSize); + } } + } - repairLines(event.fOffset); - - TextChangedEvent changeEvent = new TextChangedEvent(this); + /** + * Check if fixed width console feature is enabled or not. + * + * @return true if lines are wrapped at fixed width + * @see #getWidth() + */ + public boolean isFixedWidth() { + return fixedConsoleWidth > 0; + } - for (TextChangeListener listener : textChangeListeners) { - listener.textChanged(changeEvent); - } + /** + * Get current consoles fixed width. + * + * @return current fixed console width or <= 0 if disabled + * @see #isFixedWidth() + */ + public int getWidth() { + return fixedConsoleWidth; } /** - * sets consoleWidth, repairs line information, then fires event to the viewer text widget. - * @param width The console's width + * Set new fixed console width. Also signals text viewer widget to render + * content with new fixed width. + * + * @param width the fixed console width or <= 0 to not break lines at fixed + * width */ public void setWidth(int width) { - if (width != consoleWidth) { - consoleWidth = width; - repairLines(0); + if (width != fixedConsoleWidth) { + fixedConsoleWidth = width; + updateWidgetOffsets(0); TextChangedEvent changeEvent = new TextChangedEvent(this); for (TextChangeListener listener : textChangeListeners) { listener.textSet(changeEvent); } } } + + /** + * Log an exception and include if error occurred in fixed or non fixed width + * mode. + * + * @param error the error to log + */ + private void log(Throwable error) { + ConsolePlugin.log(ConsolePlugin.newErrorStatus("fixed width: " + isFixedWidth(), error)); //$NON-NLS-1$ + } + + // +---------------------+ + // | Debug and test code | + // +---------------------+ + /** + * The expected document length after next document change is applied. Only used + * if {@link #ASSERT} is true. + */ + private int predictedCharCount; + /** + * The expected number of lines after next document change is applied. Only used + * if {@link #ASSERT} is true. + */ + private int predictedLines; + + /** + * Set the expected document length and lines from the current document state + * and the changes predicted by the changing event. + * + * @param event the {@link TextChangingEvent} describing the next document + * change + */ + private void updatePrediction(TextChangingEvent event) { + predictedCharCount = getCharCount() + event.newCharCount - event.replaceCharCount; + predictedLines = getLineCount() + event.newLineCount - event.replaceLineCount; + } + + /** + * Check if the last change prediction was correct. Throws an + * AssertionFailedException if the prediction was wrong. + */ + private void verifyPrediction() { + final int charCount = getCharCount(); + Assert.isTrue(predictedCharCount == charCount, String.format(java.util.Locale.ROOT, + "Wrong char count. Expected<%d>, Actual<%d>", predictedCharCount, charCount)); //$NON-NLS-1$ + final int lineCount = getLineCount(); + Assert.isTrue(predictedLines == lineCount, String.format(java.util.Locale.ROOT, + "Wrong line count. Expected<%d>, Actual<%d>", predictedLines, lineCount)); //$NON-NLS-1$ + } } -- cgit v1.2.3