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); + } + } +}