diff --git a/ide/editor.lib2/src/org/netbeans/api/editor/caret/EditorCaret.java b/ide/editor.lib2/src/org/netbeans/api/editor/caret/EditorCaret.java index 64ffd85bd1ac..76a9cd9d557a 100644 --- a/ide/editor.lib2/src/org/netbeans/api/editor/caret/EditorCaret.java +++ b/ide/editor.lib2/src/org/netbeans/api/editor/caret/EditorCaret.java @@ -25,7 +25,6 @@ import java.awt.Color; import java.awt.Component; import java.awt.Composite; -import java.awt.Container; import java.awt.Cursor; import java.awt.Dimension; import java.awt.Graphics; @@ -71,7 +70,6 @@ import javax.swing.SwingUtilities; import javax.swing.Timer; import javax.swing.TransferHandler; -import javax.swing.UIManager; import javax.swing.event.ChangeEvent; import javax.swing.event.ChangeListener; import javax.swing.event.DocumentEvent; @@ -87,7 +85,6 @@ import javax.swing.text.NavigationFilter; import javax.swing.text.Position; import javax.swing.text.StyleConstants; -import javax.swing.text.Utilities; import org.netbeans.api.annotations.common.CheckForNull; import org.netbeans.api.annotations.common.NonNull; import org.netbeans.api.annotations.common.NullAllowed; @@ -1028,21 +1025,17 @@ public void deinstall(JTextComponent c) { * For that, the nearest update must reposition scroller's viewrect so that the * recomputed caretBounds is at the same */ - private synchronized void maybeSaveCaretOffset(JTextComponent c, Rectangle cbounds) { - if (c.getClientProperty("editorcaret.updateRetainsVisibleOnce") == null || // NOI18N + private synchronized void maybeSaveCaretOffset(Rectangle cbounds) { + if (component.getClientProperty("editorcaret.updateRetainsVisibleOnce") == null || // NOI18N lastCaretVisualOffset != -1) { return; } - Component parent = c.getParent(); Rectangle editorRect; - if (parent instanceof JLayeredPane) { - parent = parent.getParent(); - } - if (parent instanceof JViewport) { - final JViewport viewport = (JViewport) parent; + final JViewport viewport = getViewport(); + if (viewport != null) { editorRect = viewport.getViewRect(); } else { - Dimension size = c.getSize(); + Dimension size = component.getSize(); editorRect = new Rectangle(0, 0, size.width, size.height); } if (cbounds.y >= editorRect.y && cbounds.y < (editorRect.y + editorRect.height)) { @@ -1059,7 +1052,7 @@ private synchronized void maybeSaveCaretOffset(JTextComponent c, Rectangle cboun @Override public void paint(Graphics g) { - JTextComponent c = component; + final JTextComponent c = component; if (c == null || !isShowing()) { return; } @@ -1109,7 +1102,7 @@ public void paint(Graphics g) { Rectangle newCaretBounds = lvh.modelToViewBounds(dot, Position.Bias.Forward); Rectangle oldBounds = caretItem.setCaretBoundsWithRepaint(newCaretBounds, c, "EditorCaret.paint()", i); if (caretItem == lastCaret && oldBounds != null) { - maybeSaveCaretOffset(c, oldBounds); + maybeSaveCaretOffset(oldBounds); } } Rectangle caretBounds = caretItem.getCaretBounds(); @@ -1956,6 +1949,26 @@ private void dispatchUpdate(boolean forceInvokeLater) { */ private int lastCaretVisualOffset = -1; + private boolean isWrapping() { + // See o.n.modules.editor.lib2.view.DocumentViewOp.updateLineWrapType(). + Object lwt = null; + if (component != null) { + lwt = component.getClientProperty(SimpleValueNames.TEXT_LINE_WRAP); + if (lwt == null) { + lwt = component.getDocument().getProperty(SimpleValueNames.TEXT_LINE_WRAP); + } + } + return (lwt instanceof String) && !"none".equals(lwt); + } + + private JViewport getViewport() { + Component parent = component.getParent(); + if (parent instanceof JLayeredPane) { + parent = parent.getParent(); + } + return (parent instanceof JViewport) ? (JViewport) parent : null; + } + /** * Update the caret's visual position. *
@@ -1973,13 +1986,9 @@ private void update(boolean calledFromPaint) { if (c != null) { boolean forceUpdate = c.getClientProperty("editorcaret.updateRetainsVisibleOnce") != null; boolean log = LOG.isLoggable(Level.FINE); - Component parent = c.getParent(); Rectangle editorRect; - if (parent instanceof JLayeredPane) { - parent = parent.getParent(); - } - if (parent instanceof JViewport) { - final JViewport viewport = (JViewport) parent; + final JViewport viewport = getViewport(); + if (viewport != null) { editorRect = viewport.getViewRect(); } else { Dimension size = c.getSize(); @@ -1989,7 +1998,7 @@ private void update(boolean calledFromPaint) { Rectangle cbounds = getLastCaretItem().getCaretBounds(); if (cbounds != null) { // save relative position of the main caret - maybeSaveCaretOffset(c, cbounds); + maybeSaveCaretOffset(cbounds); } } if (!calledFromPaint && !c.isValid() /* && maintainVisible == null */) { @@ -2028,27 +2037,40 @@ private void update(boolean calledFromPaint) { } if (caretBounds != null) { Rectangle scrollBounds = new Rectangle(caretBounds); // Must possibly be cloned upon change + if (viewport != null && isWrapping()) { + /* When wrapping, only scroll to the right if the caret is + decisively outside the wrapped area (e.g. on a very long unbreakable + word). Otherwise, always scroll back to the left. When typing such + that the caret goes from the end of one wrap line to the next, the + new caret position might be one or more characters away from the + first character on the wrap line, so a regular + scroll-to-make-the-caret-visible would not do the job. */ + if (scrollBounds.x <= viewport.getExtentSize().width) { + scrollBounds.x = 0; + scrollBounds.width = 1; + /* Avoid generating a drag-select as a result of the viewport + being automatically scrolled back to x=0 as a result of the user + clicking once to move the caret. */ + if (viewport.getViewPosition().x > 0 && getDot() == getMark()) { + mouseState = MouseState.DEFAULT; + } + } + } // Only scroll the view for the LAST caret to be visible // For null old bounds (likely at begining of component displayment) ensure that a possible // horizontal scrollbar would not hide the caret so enlarge the scroll bounds by hscrollbar height. - if (oldCaretBounds == null) { - Component viewport = c.getParent(); - if (viewport instanceof JLayeredPane) { - viewport = viewport.getParent(); - } - if (viewport instanceof JViewport) { - Component scrollPane = viewport.getParent(); - if (scrollPane instanceof JScrollPane) { - JScrollBar hScrollBar = ((JScrollPane) scrollPane).getHorizontalScrollBar(); - if (hScrollBar != null) { - int hScrollBarHeight = hScrollBar.getPreferredSize().height; - Dimension extentSize = ((JViewport) viewport).getExtentSize(); - // If the extent size is high enough then extend - // the scroll region by extra vertical space - if (extentSize.height >= caretBounds.height + hScrollBarHeight) { - scrollBounds = new Rectangle(scrollBounds); // Clone - scrollBounds.height += hScrollBarHeight; - } + if (oldCaretBounds == null && viewport != null) { + Component scrollPane = viewport.getParent(); + if (scrollPane instanceof JScrollPane) { + JScrollBar hScrollBar = ((JScrollPane) scrollPane).getHorizontalScrollBar(); + if (hScrollBar != null) { + int hScrollBarHeight = hScrollBar.getPreferredSize().height; + Dimension extentSize = ((JViewport) viewport).getExtentSize(); + // If the extent size is high enough then extend + // the scroll region by extra vertical space + if (extentSize.height >= caretBounds.height + hScrollBarHeight) { + scrollBounds = new Rectangle(scrollBounds); // Clone + scrollBounds.height += hScrollBarHeight; } } } @@ -2586,12 +2608,9 @@ public void actionPerformed(ActionEvent e) { // Blinker timer fired // and if it's fired the caret's bounds will be checked whether // they intersect with the horizontal scrollbar // and if so the view will be scrolled. - Container parent = component.getParent(); - if(parent instanceof JLayeredPane) { - parent = parent.getParent(); - } - if (parent instanceof JViewport) { - parent = parent.getParent(); // parent of viewport + final JViewport viewport = getViewport(); + if (viewport != null) { + Component parent = viewport.getParent(); if (parent instanceof JScrollPane) { JScrollPane scrollPane = (JScrollPane) parent; JScrollBar hScrollBar = scrollPane.getHorizontalScrollBar(); diff --git a/ide/editor.lib2/src/org/netbeans/modules/editor/lib2/view/CharSequenceCharacterIterator.java b/ide/editor.lib2/src/org/netbeans/modules/editor/lib2/view/CharSequenceCharacterIterator.java new file mode 100644 index 000000000000..e4726efd54da --- /dev/null +++ b/ide/editor.lib2/src/org/netbeans/modules/editor/lib2/view/CharSequenceCharacterIterator.java @@ -0,0 +1,107 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.netbeans.modules.editor.lib2.view; + +import java.text.CharacterIterator; + +/* This class was written from scratch. I considered using org.apache.pivot.text.CharacterIterator +from the Apache Pivot project, but it had bugs in the next() and previous() methods (trying to use +CharacterIterator.DONE = 65535 as an index). */ +/** + * Adapter class for providing a {@link CharacterIterator} over a {@link CharSequence} without + * making a copy of the entire underlying string. + * + * @author Eirik Bakke (ebakke@ultorg.com) + */ +class CharSequenceCharacterIterator implements CharacterIterator { + private final CharSequence charSequence; + private int index; + + public CharSequenceCharacterIterator(CharSequence charSequence) { + if (charSequence == null) { + throw new NullPointerException(); + } + this.charSequence = charSequence; + } + + @Override + public int getIndex() { + return index; + } + + @Override + public char setIndex(int position) { + if (position < getBeginIndex() || position > getEndIndex()) { + throw new IllegalArgumentException(); + } + this.index = position; + return current(); + } + + @Override + public char current() { + return index == getEndIndex() ? CharacterIterator.DONE : charSequence.charAt(index); + } + + @Override + public char first() { + return setIndex(getBeginIndex()); + } + + @Override + public char last() { + final int endIndex = getEndIndex(); + return setIndex(charSequence.length() == 0 ? endIndex : (endIndex - 1)); + } + + @Override + public char next() { + if (index < getEndIndex()) { + index++; + } + return current(); + } + + @Override + public char previous() { + if (index > getBeginIndex()) { + index--; + return current(); + } else { + return CharacterIterator.DONE; + } + } + + @Override + public int getBeginIndex() { + return 0; + } + + @Override + public int getEndIndex() { + return charSequence.length(); + } + + @Override + public Object clone() { + CharacterIterator ret = new CharSequenceCharacterIterator(charSequence); + ret.setIndex(index); + return ret; + } +} diff --git a/ide/editor.lib2/src/org/netbeans/modules/editor/lib2/view/DocumentViewOp.java b/ide/editor.lib2/src/org/netbeans/modules/editor/lib2/view/DocumentViewOp.java index b95c60d218c5..a226a303e8eb 100644 --- a/ide/editor.lib2/src/org/netbeans/modules/editor/lib2/view/DocumentViewOp.java +++ b/ide/editor.lib2/src/org/netbeans/modules/editor/lib2/view/DocumentViewOp.java @@ -1227,10 +1227,12 @@ float getAvailableWidth() { setStatusBits(AVAILABLE_WIDTH_VALID); availableWidth = Integer.MAX_VALUE; renderWrapWidth = availableWidth; - TextLayout lineContTextLayout = getLineContinuationCharTextLayout(); - if (lineContTextLayout != null && (getLineWrapType() != LineWrapType.NONE)) { - availableWidth = Math.max(getVisibleRect().width, 4 * getDefaultCharWidth() + lineContTextLayout.getAdvance()); - renderWrapWidth = availableWidth - lineContTextLayout.getAdvance(); + if (getLineWrapType() != LineWrapType.NONE) { + final TextLayout lineContTextLayout = getLineContinuationCharTextLayout(); + final float lineContTextLayoutAdvance = + lineContTextLayout == null ? 0f : lineContTextLayout.getAdvance(); + availableWidth = Math.max(getVisibleRect().width, 4 * getDefaultCharWidth() + lineContTextLayoutAdvance); + renderWrapWidth = availableWidth - lineContTextLayoutAdvance; } } return availableWidth; @@ -1343,7 +1345,17 @@ TextLayout getTabCharTextLayout(double availableWidth) { return ret; } + /** + * @return will be null if the line continuation character should not be shown + */ TextLayout getLineContinuationCharTextLayout() { + /* The line continuation character is used to show that a line is automatically being + broken into multiple wrap lines via the line wrap feature. This causes a lot of visual + clutter, and always takes up an extra character of horizontal space, so don't show it by + default. The same information is communicated by the line numbers in the left-hand side of + the editor anyway. */ + if (!docView.op.isNonPrintableCharactersVisible()) + return null; if (lineContinuationTextLayout == null) { char lineContinuationChar = LINE_CONTINUATION; if (!defaultFont.canDisplay(lineContinuationChar)) { diff --git a/ide/editor.lib2/src/org/netbeans/modules/editor/lib2/view/HighlightsViewUtils.java b/ide/editor.lib2/src/org/netbeans/modules/editor/lib2/view/HighlightsViewUtils.java index 6666a4265806..f13c6e78517e 100644 --- a/ide/editor.lib2/src/org/netbeans/modules/editor/lib2/view/HighlightsViewUtils.java +++ b/ide/editor.lib2/src/org/netbeans/modules/editor/lib2/view/HighlightsViewUtils.java @@ -28,8 +28,9 @@ import java.awt.font.TextHitInfo; import java.awt.font.TextLayout; import java.awt.geom.Rectangle2D; +import java.text.BreakIterator; +import java.util.Locale; import java.util.logging.Level; -import java.util.logging.Logger; import javax.swing.text.AttributeSet; import javax.swing.text.Caret; import javax.swing.text.JTextComponent; @@ -714,52 +715,30 @@ static View breakView(int axis, int breakPartStartOffset, float x, float len, } } - // Now perform corrections if wrapping at word boundaries is required - // Currently a simple impl that checks adjacent char(s) in backward direction - // is used. Consider BreakIterator etc. if requested. - // If break is inside a word then check for word boundary in backward direction. - // If none is found then go forward to find a word break if possible. int breakPartEndOffset = partStartOffset + hitInfo.getCharIndex(); if (breakPartEndOffset > breakPartStartOffset) { + // Now perform corrections if wrapping at word boundaries is required if (docView.op.getLineWrapType() == LineWrapType.WORD_BOUND) { - CharSequence docText = DocumentUtilities.getText(docView.getDocument()); - if (breakPartEndOffset > breakPartStartOffset) { - boolean searchNonLetterForward = false; - char ch = docText.charAt(breakPartEndOffset - 1); - // [TODO] Check surrogates - if (Character.isLetterOrDigit(ch)) { - if (breakPartEndOffset < docText.length() && - Character.isLetterOrDigit(docText.charAt(breakPartEndOffset))) - { - // Inside word - // Attempt to go back and search non-letter - int offset = breakPartEndOffset - 1; - while (offset >= breakPartStartOffset && Character.isLetterOrDigit(docText.charAt(offset))) { - offset--; - } - offset++; - if (offset == breakPartStartOffset) { - searchNonLetterForward = true; - } else { // move the break offset back - breakPartEndOffset = offset; - } - } - } - if (searchNonLetterForward) { - breakPartEndOffset++; // char at breakPartEndOffset already checked - while (breakPartEndOffset < partStartOffset + partLength && - Character.isLetterOrDigit(docText.charAt(breakPartEndOffset))) - { - breakPartEndOffset++; - } - } - } + CharSequence paragraph = DocumentUtilities.getText(docView.getDocument()) + .subSequence(breakPartStartOffset, partStartOffset + partLength); + /* Don't enable allowWhitespaceBeyondEnd if we are printing the line + continuation character + (see DocumentViewOp.getLineContinuationCharTextLayout), since the latter + would usually then end up beyond the edge of the editor viewport. */ + boolean allowWhitespaceBeyondEnd = + !docView.op.isNonPrintableCharactersVisible(); + breakPartEndOffset = adjustBreakOffsetToWord(paragraph, + breakPartEndOffset - breakPartStartOffset, + allowWhitespaceBeyondEnd) + breakPartStartOffset; } } // Length must be > 0; BTW TextLayout can't be constructed with empty string. - boolean breakFailed = (breakPartEndOffset - breakPartStartOffset == 0) || - (breakPartEndOffset - breakPartStartOffset >= partLength); + boolean doNotBreak = + // No need to split the line in two if the first part would be empty. + (breakPartEndOffset - breakPartStartOffset == 0) || + // No need to split the line in two if the second part would be empty. + (breakPartEndOffset - breakPartStartOffset >= partLength); // if (ViewHierarchyImpl.BUILD_LOG.isLoggable(Level.FINE)) { // ViewHierarchyImpl.BUILD_LOG.fine("HV.breakView(): <" + partStartOffset + // NOI18N // "," + (partStartOffset+partLength) + // NOI18N @@ -767,7 +746,7 @@ static View breakView(int axis, int breakPartStartOffset, float x, float len, // ">, x=" + x + ", len=" + len + // NOI18N // ", charIndexX=" + breakCharIndexX + "\n"); // NOI18N // } - if (breakFailed) { + if (doNotBreak) { return null; } return new HighlightsViewPart(fullView, breakPartStartOffset - fullViewStartOffset, @@ -777,4 +756,90 @@ static View breakView(int axis, int breakPartStartOffset, float x, float len, return null; } + // Package-private for testing. + /** + * Calculate the position at which to break a line in a paragraph. A break offset of X means + * that the character with index (X-1) in {@code paragraph} will be the last one on the physical + * line. + * + *

The current implementation avoids creating lines with leading whitespace (when words are + * separated by at most one whitespace character), allows lines to be broken after hyphens, and, + * if {@code allowWhitespaceBeyondEnd} is true, allows one whitespace character to extend beyond + * the preferred break width to make use of all available horizontal space. Very long + * unbreakable words may extend beyond the preferred break offset regardless of the setting of + * {@code allowWhitespaceBeyondEnd}. + * + *

It was previously considered to allow an arbitrary number of whitespace characters to + * trail off the end of each wrap line, rather than just one. In the end, it turned out to be + * better to limit this to just one character, as this conveniently avoids the need to ever + * position the visual text caret outside the word-wrapped editor viewport (except in cases of + * very long unbreakable words). + * + * @param paragraph a long line of text to be broken, i.e. a paragraph, or the remainder of a + * paragraph if some of its initial lines of wrapped text have already been laid out + * @param preferredMaximumBreakOffset the preferred maximum break offset + * @param allowWhitespaceBeyondEnd if true, allow one whitespace character to extend beyond + * {@code preferredMaximumBreakOffset} even when this could be avoided by choosing a + * smaller break offset + */ + static int adjustBreakOffsetToWord(CharSequence paragraph, + final int preferredMaximumBreakOffset, boolean allowWhitespaceBeyondEnd) + { + if (preferredMaximumBreakOffset < 0) { + throw new IllegalArgumentException(); + } + if (preferredMaximumBreakOffset > paragraph.length()) { + throw new IllegalArgumentException(); + } + /* BreakIterator.getLineInstance already seems to have a cache; creating a new instance here + is just the cost of BreakIterator.clone(). So don't bother trying to cache the BreakIterator + here. */ + BreakIterator bi = BreakIterator.getLineInstance(Locale.US); + /* Use CharSequenceCharacterIterator to avoid copying the entire paragraph string every + time. */ + bi.setText(new CharSequenceCharacterIterator(paragraph)); + + int ret; + if (preferredMaximumBreakOffset == 0) { + // Skip forward to next boundary. + ret = 0; + } else if ( + allowWhitespaceBeyondEnd && preferredMaximumBreakOffset < paragraph.length() && + Character.isWhitespace(paragraph.charAt(preferredMaximumBreakOffset))) + { + // Allow one whitespace character to extend beyond the preferred break offset. + return preferredMaximumBreakOffset + 1; + } else { + // Skip backwards to previous boundary. + ret = bi.isBoundary(preferredMaximumBreakOffset) + ? preferredMaximumBreakOffset + : bi.preceding(preferredMaximumBreakOffset); + if (ret == BreakIterator.DONE) { + return preferredMaximumBreakOffset; + } + } + if (ret == 0) { + // Skip forward to next boundary (for words longer than the preferred break offset). + ret = preferredMaximumBreakOffset > 0 && bi.isBoundary(preferredMaximumBreakOffset) + ? preferredMaximumBreakOffset + : bi.following(preferredMaximumBreakOffset); + if (ret == BreakIterator.DONE) { + ret = preferredMaximumBreakOffset; + } + /* The line-based break iterator will include whitespace trailing a word as well. Strip + this off so we can apply our own policy here. */ + int retBeforeTrim = ret; + while (ret > preferredMaximumBreakOffset && + Character.isWhitespace(paragraph.charAt(ret - 1))) + { + ret--; + } + /* If allowWhitespaceBeyondEnd is true, allow at most one whitespace character to trail + the word at the end. */ + if ((allowWhitespaceBeyondEnd || ret == 0) && retBeforeTrim > ret) { + ret++; + } + } + return ret; + } } diff --git a/ide/editor.lib2/src/org/netbeans/modules/editor/lib2/view/WrapInfo.java b/ide/editor.lib2/src/org/netbeans/modules/editor/lib2/view/WrapInfo.java index 0446647998da..9e48bd56cac2 100644 --- a/ide/editor.lib2/src/org/netbeans/modules/editor/lib2/view/WrapInfo.java +++ b/ide/editor.lib2/src/org/netbeans/modules/editor/lib2/view/WrapInfo.java @@ -122,7 +122,7 @@ void paintWrapLines(ParagraphViewChildren children, ParagraphView pView, allocBounds.x += endPart.width; } // Paint wrap mark - if (i != lastWrapLineIndex) { // but not on last wrap line + if (lineContinuationTextLayout != null && i != lastWrapLineIndex) { // but not on last wrap line PaintState paintState = PaintState.save(g); try { ViewUtils.applyForegroundColor(g, null, textComponent); diff --git a/ide/editor.lib2/src/org/netbeans/modules/editor/lib2/view/WrapInfoUpdater.java b/ide/editor.lib2/src/org/netbeans/modules/editor/lib2/view/WrapInfoUpdater.java index 497873f69e9c..335d4382ee14 100644 --- a/ide/editor.lib2/src/org/netbeans/modules/editor/lib2/view/WrapInfoUpdater.java +++ b/ide/editor.lib2/src/org/netbeans/modules/editor/lib2/view/WrapInfoUpdater.java @@ -24,6 +24,7 @@ import java.util.List; import java.util.logging.Level; import java.util.logging.Logger; +import javax.swing.text.BadLocationException; import javax.swing.text.View; import org.netbeans.lib.editor.util.swing.DocumentUtilities; @@ -101,14 +102,16 @@ void initWrapInfo() { wrapTypeWords = (docView.op.getLineWrapType() == LineWrapType.WORD_BOUND); float visibleWidth = docView.op.getVisibleRect().width; TextLayout lineContinuationTextLayout = docView.op.getLineContinuationCharTextLayout(); + final float lineContTextLayoutAdvance = + lineContinuationTextLayout == null ? 0f : lineContinuationTextLayout.getAdvance(); // Make reasonable minimum width so that the number of visual lines does not double suddenly // when user would minimize the width too much. Also have enough space for line continuation mark - availableWidth = Math.max(visibleWidth - lineContinuationTextLayout.getAdvance(), + availableWidth = Math.max(visibleWidth - lineContTextLayoutAdvance, docView.op.getDefaultCharWidth() * 4); logMsgBuilder = LOG.isLoggable(Level.FINE) ? new StringBuilder(100) : null; if (logMsgBuilder != null) { logMsgBuilder.append("Building wrapLines: availWidth=").append(availableWidth); // NOI18N - logMsgBuilder.append(", lineContCharWidth=").append(lineContinuationTextLayout.getAdvance()); // NOI18N + logMsgBuilder.append(", lineContCharWidth=").append(lineContTextLayoutAdvance); // NOI18N logMsgBuilder.append("\n"); // NOI18N } try { @@ -131,7 +134,7 @@ void initWrapInfo() { WordInfo wordInfo = getWordInfo(viewOrPartStartOffset, wrapLineStartOffset); if (wordInfo != null) { // Attempt to break the view (at word boundary) so that it fits. - ViewSplit split = breakView(viewOrPart, false); + ViewSplit split = breakView(viewOrPart, true); if (split != null) { addPart(split.startPart); finishWrapLine(); @@ -176,18 +179,49 @@ void initWrapInfo() { } if (regularBreak) { - ViewSplit split = breakView(viewOrPart, false); + /* Use allowWider=true here, so that long words are allowed to extend beyond + the preferred wrap width. Turning it off would just give up on breaking + entirely for the rest of the paragraph, yielding an even longer physical + line. */ + ViewSplit split = breakView(viewOrPart, true); if (split != null) { addPart(split.startPart); viewOrPart = split.endPart; - finishWrapLine(); } else { // break failed if (!wrapLineNonEmpty) { addViewOrPart(viewOrPart); viewOrPart = fetchNextView(); } - finishWrapLine(); } + /* Keep the NewlineView that follows each paragraph together with the + paragraph's last wrap line. Otherwise, the NewlineView might wrap to the + next physical line if the last wrap line of the paragraph happens to be + exactly as long as the availableWidth. This would make the text caret, if + positioned at the end of a paragraph, end up being visually positioned on + the beginning of the next line instead of at the end of the current one. */ + if (viewOrPart != null && viewOrPart.view instanceof NewlineView) { + /* One exception: If the wrap line ends with a space, it's actually + better to allow the NewlineView, and thus the text caret, to wrap to the + next physical line, since this is where the user's next typed character + will end up. This also avoids the need to position the caret outside the + viewport in a few cases (due to the text wrapping policy of allowing + whitespace characters at the end of each wrap line to extend beyond the + preferred wrap width). */ + boolean wrapLineEndsWithSpace = false; + try { + int newlineOffset = viewOrPart.view.getStartOffset(); + wrapLineEndsWithSpace = newlineOffset > 0 && + Character.isWhitespace(viewOrPart.view.getDocument() + .getText(newlineOffset - 1, 1).charAt(0)); + } catch (BadLocationException e) { + // Ignore. + } + if (!wrapLineEndsWithSpace) { + addViewOrPart(viewOrPart); + viewOrPart = fetchNextView(); + } + } + finishWrapLine(); } } } while (childIndex < pView.getViewCount()); @@ -601,6 +635,10 @@ int wordStartOffset() { return wordStartOffset; } + @Override + public String toString() { + return "WordInfo(" + wordStartOffset() + ", " + wordEndOffset() + ")"; + } } diff --git a/ide/editor.lib2/test/unit/src/org/netbeans/modules/editor/lib2/view/AdjustBreakOffsetToWordTest.java b/ide/editor.lib2/test/unit/src/org/netbeans/modules/editor/lib2/view/AdjustBreakOffsetToWordTest.java new file mode 100644 index 000000000000..278753093155 --- /dev/null +++ b/ide/editor.lib2/test/unit/src/org/netbeans/modules/editor/lib2/view/AdjustBreakOffsetToWordTest.java @@ -0,0 +1,284 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.netbeans.modules.editor.lib2.view; + +import java.util.Objects; +import org.junit.Assert; +import org.junit.Test; + +/** + * Test for {@link HighlightsViewUtils#adjustBreakOffsetToWord(CharSequence,int,boolean)}. + */ +public class AdjustBreakOffsetToWordTest { + private void testOne(String original, String expected, boolean allowWhitespaceBeyondEnd) { + TextWithCaret originalTwC = TextWithCaret.fromEncoded(original); + TextWithCaret expectedTwC = TextWithCaret.fromEncoded(expected); + TextWithCaret actualTwC = new TextWithCaret(originalTwC.text, + HighlightsViewUtils.adjustBreakOffsetToWord(originalTwC.text, originalTwC.caret, + allowWhitespaceBeyondEnd)); + Assert.assertEquals(expectedTwC, actualTwC); + } + + private void testOne(String original, String expected) { + testOne(original, expected, false); + testOne(original, expected, true); + } + + private void testOne(String original, String + expectedDisallowWhitespaceBeyondEnd, String expectedAllowWhitespaceBeyondEnd) + { + testOne(original, expectedDisallowWhitespaceBeyondEnd, false); + testOne(original, expectedAllowWhitespaceBeyondEnd, true); + } + + @Test + public void testLongWord() { + // Forward-skipping is necessary. + testOne("|this is a test", "this| is a test", "this |is a test"); + testOne("t|his is a test", "this| is a test", "this |is a test"); + testOne("th|is is a test", "this| is a test", "this |is a test"); + testOne("thi|s is a test", "this| is a test", "this |is a test"); + testOne("this| is a test", "this| is a test", "this |is a test"); + } + + @Test + public void testBasic() { + // Forward-skipping is not necessary. + testOne("this |is a test", "this |is a test"); + testOne("this i|s a test", "this |is a test"); + /* If !allowWhitespaceBeyondEnd, break must backtrack to before "is" to avoid the new line + starting with a whitespace. */ + testOne("this is| a test", "this |is a test", "this is |a test"); + testOne("this is |a test", "this is |a test"); + testOne("this is a| test", "this is |a test", "this is a |test"); + testOne("this is a |test", "this is a |test"); + testOne("this is a t|est", "this is a |test"); + testOne("this is a te|st", "this is a |test"); + testOne("this is a tes|t", "this is a |test"); + testOne("this is a test|", "this is a test|"); + } + + @Test + public void testDashInFirstWord() { + testOne("|foo-test bar is", "foo-|test bar is"); + testOne("f|oo-test bar is", "foo-|test bar is"); + testOne("fo|o-test bar is", "foo-|test bar is"); + testOne("foo|-test bar is", "foo-|test bar is"); + testOne("foo-|test bar is", "foo-|test bar is"); + testOne("foo-t|est bar is", "foo-|test bar is"); + testOne("foo-te|st bar is", "foo-|test bar is"); + testOne("foo-tes|t bar is", "foo-|test bar is"); + testOne("foo-test| bar is", "foo-|test bar is", "foo-test |bar is"); + testOne("foo-test |bar is", "foo-test |bar is"); + testOne("foo-test b|ar is", "foo-test |bar is"); + } + + @Test + public void testDashInNonFirstWord() { + testOne("this is| foo-test bar", "this |is foo-test bar", "this is |foo-test bar"); + testOne("this is |foo-test bar", "this is |foo-test bar"); + testOne("this is f|oo-test bar", "this is |foo-test bar"); + testOne("this is fo|o-test bar", "this is |foo-test bar"); + testOne("this is foo|-test bar", "this is |foo-test bar"); + testOne("this is foo-|test bar", "this is foo-|test bar"); + testOne("this is foo-t|est bar", "this is foo-|test bar"); + testOne("this is foo-te|st bar", "this is foo-|test bar"); + testOne("this is foo-tes|t bar", "this is foo-|test bar"); + testOne("this is foo-test| bar", "this is foo-|test bar", "this is foo-test |bar"); + testOne("this is foo-test |bar", "this is foo-test |bar"); + testOne("this is foo-test b|ar", "this is foo-test |bar"); + } + + @Test + public void testJustWhitespace() { + testOne("|", "|"); + testOne("| ", " |"); + testOne(" |", " |"); + testOne("| ", " | "); + testOne(" | ", " | ", " |"); + testOne(" |", " |"); + testOne("| ", " | "); + testOne(" | ", " | ", " | "); + testOne(" | ", " | ", " |"); + testOne(" |", " |"); + } + + @Test + public void testTrailingNewline() { + // A newline character should just be treated like any other whitespace here. + testOne("|this is a test\n", "this| is a test\n", "this |is a test\n"); + testOne("t|his is a test\n", "this| is a test\n", "this |is a test\n"); + testOne("th|is is a test\n", "this| is a test\n", "this |is a test\n"); + testOne("thi|s is a test\n", "this| is a test\n", "this |is a test\n"); + testOne("this| is a test\n", "this| is a test\n", "this |is a test\n"); + testOne("this |is a test\n", "this |is a test\n"); + testOne("this i|s a test\n", "this |is a test\n"); + testOne("this is| a test\n", "this |is a test\n", "this is |a test\n"); + testOne("this is |a test\n", "this is |a test\n"); + testOne("this is a| test\n", "this is |a test\n", "this is a |test\n"); + testOne("this is a |test\n", "this is a |test\n"); + testOne("this is a t|est\n", "this is a |test\n"); + testOne("this is a te|st\n", "this is a |test\n"); + testOne("this is a tes|t\n", "this is a |test\n"); + testOne("this is a test|\n", "this is a |test\n", "this is a test\n|"); + testOne("this is a test\n|", "this is a test\n|"); + } + + @Test + public void testSpacesBetween() { + // Multiple whitespace characters between first and second word. + testOne("th|is is a test", "this| is a test", "this | is a test"); + testOne("thi|s is a test", "this| is a test", "this | is a test"); + testOne("this| is a test", "this| is a test", "this | is a test"); + testOne("this | is a test", "this | is a test", "this | is a test"); + testOne("this | is a test", "this | is a test", "this |is a test"); + testOne("this |is a test", "this |is a test"); + testOne("this i|s a test", "this |is a test"); + testOne("this is| a test", "this |is a test", "this is |a test"); + testOne("this is |a test", "this is |a test"); + // Multiple whitespace characters between words not including the first word. + testOne("this |is aaaa test", "this |is aaaa test"); + testOne("this i|s aaaa test", "this |is aaaa test"); + testOne("this is| aaaa test", "this |is aaaa test", "this is | aaaa test"); + testOne("this is | aaaa test", "this |is aaaa test", "this is | aaaa test"); + testOne("this is | aaaa test", "this |is aaaa test", "this is |aaaa test"); + testOne("this is |aaaa test", "this is |aaaa test"); + testOne("this is a|aaa test", "this is |aaaa test"); + testOne("this is aa|aa test", "this is |aaaa test"); + testOne("this is aaa|a test", "this is |aaaa test"); + testOne("this is aaaa| test", "this is |aaaa test", "this is aaaa |test"); + testOne("this is aaaa |test", "this is aaaa |test"); + } + + @Test + public void testTrailingSpaces() { + // One trailing whitespace character. + testOne("this is a test| ", "this is a |test ", "this is a test |"); + testOne("this is a test |", "this is a test |"); + // Two trailing whitespace characters. + testOne("this is a tes|t ", "this is a |test ", "this is a |test "); + testOne("this is a test| ", "this is a |test ", "this is a test | "); + testOne("this is a test | ", "this is a |test ", "this is a test |"); + testOne("this is a test |", "this is a test |", "this is a test |"); + // Long line with one trailing whitespace character. + testOne("|testtest ", "testtest| ", "testtest |"); + testOne("t|esttest ", "testtest| ", "testtest |"); + testOne("testte|st ", "testtest| ", "testtest |"); + testOne("testtes|t ", "testtest| ", "testtest |"); + testOne("testtest| ", "testtest| ", "testtest |"); + testOne("testtest |", "testtest |", "testtest |"); + // Long line with two trailing whitespace characters. + testOne("|testtest ", "testtest| ", "testtest | "); + testOne("t|esttest ", "testtest| ", "testtest | "); + testOne("testte|st ", "testtest| ", "testtest | "); + testOne("testtes|t ", "testtest| ", "testtest | "); + testOne("testtest| ", "testtest| ", "testtest | "); + testOne("testtest | ", "testtest | ", "testtest |"); + testOne("testtest |", "testtest |"); + } + + @Test + public void testAdjustBreakOffsetToWordLeadingSpace() { + // One leading space + testOne("| this is a test", " |this is a test"); + testOne(" |this is a test", " |this is a test"); + testOne(" t|his is a test", " |this is a test"); + testOne(" th|is is a test", " |this is a test"); + testOne(" thi|s is a test", " |this is a test"); + testOne(" this| is a test", " |this is a test", " this |is a test"); + testOne(" this |is a test", " this |is a test"); + testOne(" this i|s a test", " this |is a test"); + // Two leading spaces + testOne("| this is a test", " | this is a test"); + testOne(" | this is a test", " | this is a test", " |this is a test"); + testOne(" |this is a test", " |this is a test"); + testOne(" t|his is a test", " |this is a test"); + testOne(" th|is is a test", " |this is a test"); + testOne(" thi|s is a test", " |this is a test"); + testOne(" this| is a test", " |this is a test", " this |is a test"); + testOne(" this |is a test", " this |is a test"); + testOne(" this i|s a test", " this |is a test"); + } + + private static final class TextWithCaret { + public final String text; + public final int caret; + + public TextWithCaret(String text, int caret) { + this(text, caret, true); + } + + private TextWithCaret(String text, int caret, boolean check) { + if (text == null) { + throw new NullPointerException(); + } + if (caret < 0 || caret > text.length()) { + throw new IllegalArgumentException(); + } + this.text = text; + this.caret = caret; + + if (check && !equals(fromEncoded(toString()))) { + throw new AssertionError(); + } + } + + /** + * @param encoded string containing a single '|' character indicating the caret position + */ + public static TextWithCaret fromEncoded(String encoded) { + final StringBuilder psb = new StringBuilder(); + Integer caret = null; + for (char ec : encoded.toCharArray()) { + if (ec == '|') { + if (caret != null) { + throw new IllegalArgumentException("Multiple caret positions specified"); + } + caret = psb.length(); + } else { + psb.append(ec); + } + } + if (caret == null) { + throw new IllegalArgumentException("No caret position specified"); + } + return new TextWithCaret(psb.toString(), caret, false); + } + + @Override + public String toString() { + return text.substring(0, caret) + '|' + text.substring(caret); + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof TextWithCaret)) { + return false; + } + TextWithCaret other = (TextWithCaret) obj; + return this.text.equals(other.text) + && this.caret == other.caret; + } + + @Override + public int hashCode() { + return Objects.hash(text, caret); + } + } +}