/* * Copyright 2000-2016 JetBrains s.r.o. * * Licensed 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 com.intellij.openapi.editor.impl.softwrap.mapping; import com.intellij.diagnostic.Dumpable; import com.intellij.diagnostic.LogMessageEx; import com.intellij.openapi.diagnostic.Attachment; import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.editor.*; import com.intellij.openapi.editor.event.DocumentEvent; import com.intellij.openapi.editor.ex.DocumentEx; import com.intellij.openapi.editor.ex.ScrollingModelEx; import com.intellij.openapi.editor.ex.util.EditorUtil; import com.intellij.openapi.editor.impl.*; import com.intellij.openapi.editor.impl.softwrap.SoftWrapDrawingType; import com.intellij.openapi.editor.impl.softwrap.SoftWrapImpl; import com.intellij.openapi.editor.impl.softwrap.SoftWrapPainter; import com.intellij.openapi.editor.impl.softwrap.SoftWrapsStorage; import com.intellij.openapi.editor.impl.view.IterationState; import com.intellij.openapi.editor.markup.TextAttributes; import com.intellij.openapi.util.Segment; import com.intellij.openapi.util.text.StringUtil; import com.intellij.util.DocumentUtil; import org.intellij.lang.annotations.JdkConstants; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.TestOnly; import javax.swing.*; import java.awt.*; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; /** * The general idea of soft wraps processing is to build a cache to use for quick document dimensions mapping * ({@code 'logical position -> visual position'}, {@code 'offset -> logical position'} etc) and update it incrementally * on events like document modification fold region(s) expanding/collapsing etc. * <p/> * This class encapsulates document parsing logic. It notifies {@link SoftWrapAwareDocumentParsingListener registered listeners} * about parsing and they are free to store necessary information for further usage. * <p/> * Not thread-safe. * * @author Denis Zhdanov * @since Jul 5, 2010 10:01:27 AM */ public class SoftWrapApplianceManager implements Dumpable { private static final Logger LOG = Logger.getInstance(SoftWrapApplianceManager.class); /** Enumerates possible type of soft wrap indents to use. */ enum IndentType { /** Don't apply special indent to soft-wrapped line at all. */ NONE, /** * Indent soft wraps for the {@link EditorSettings#getCustomSoftWrapIndent() user-defined number of columns} * to the start of the previous visual line. */ CUSTOM } private final List<SoftWrapAwareDocumentParsingListener> myListeners = new ArrayList<>(); private final ProcessingContext myContext = new ProcessingContext(); private final FontTypesStorage myOffset2fontType = new FontTypesStorage(); private final WidthsStorage myOffset2widthInPixels = new WidthsStorage(); private final SoftWrapsStorage myStorage; private final EditorImpl myEditor; private SoftWrapPainter myPainter; private final CachingSoftWrapDataMapper myDataMapper; /** * Visual area width change causes soft wraps addition/removal, so, we want to update <code>'y'</code> coordinate * of the editor viewport then. For example, we observe particular text region at the 'vcs diff' control and change * its width. We would like to see the same text range at the viewport then. * <p/> * This field holds offset of the text range that is shown at the top-left viewport position. It's used as an anchor * during viewport's <code>'y'</code> coordinate adjustment on visual area width change. */ private int myLastTopLeftCornerOffset = 0; private int myVerticalScrollBarWidth = -1; private VisibleAreaWidthProvider myWidthProvider; private LineWrapPositionStrategy myLineWrapPositionStrategy; private IncrementalCacheUpdateEvent myEventBeingProcessed; private boolean myCustomIndentUsedLastTime; private int myCustomIndentValueUsedLastTime; private int myVisibleAreaWidth; private boolean myInProgress; private boolean myIsDirty = true; private IncrementalCacheUpdateEvent myDocumentChangedEvent; @NotNull private final Rectangle myAvailableArea = new Rectangle(); // mutable public SoftWrapApplianceManager(@NotNull SoftWrapsStorage storage, @NotNull EditorImpl editor, @NotNull SoftWrapPainter painter, CachingSoftWrapDataMapper dataMapper) { myStorage = storage; myEditor = editor; myPainter = painter; myDataMapper = dataMapper; myWidthProvider = new DefaultVisibleAreaWidthProvider(editor); myEditor.getScrollingModel().addVisibleAreaListener(e -> { updateAvailableArea(); updateLastTopLeftCornerOffset(); }); } public void registerSoftWrapIfNecessary() { recalculateIfNecessary(); } public void reset() { myIsDirty = true; for (SoftWrapAwareDocumentParsingListener listener : myListeners) { listener.reset(); } } public void release() { myLineWrapPositionStrategy = null; } public void recalculate(IncrementalCacheUpdateEvent e) { if (myIsDirty) { return; } if (myVisibleAreaWidth <= 0) { myIsDirty = true; return; } recalculateSoftWraps(e); onRecalculationEnd(); } public void recalculate(List<? extends Segment> ranges) { if (myIsDirty) { return; } if (myVisibleAreaWidth <= 0) { myIsDirty = true; return; } Collections.sort(ranges, (o1, o2) -> { int startDiff = o1.getStartOffset() - o2.getStartOffset(); return startDiff == 0 ? o2.getEndOffset() - o1.getEndOffset() : startDiff; }); final int[] lastRecalculatedOffset = new int[] {0}; SoftWrapAwareDocumentParsingListenerAdapter listener = new SoftWrapAwareDocumentParsingListenerAdapter() { @Override public void onRecalculationEnd(@NotNull IncrementalCacheUpdateEvent event) { lastRecalculatedOffset[0] = event.getActualEndOffset(); } }; myListeners.add(listener); try { for (Segment range : ranges) { int lastOffset = lastRecalculatedOffset[0]; if (range.getEndOffset() > lastOffset) { recalculateSoftWraps(new IncrementalCacheUpdateEvent(Math.max(range.getStartOffset(), lastOffset), range.getEndOffset(), myEditor)); } } } finally { myListeners.remove(listener); } onRecalculationEnd(); } /** * @return <code>true</code> if soft wraps were really re-calculated; * <code>false</code> if it's not possible to do at the moment (e.g. current editor is not shown and we don't * have information about viewport width) */ private boolean recalculateSoftWraps() { if (!myIsDirty) { return true; } if (myVisibleAreaWidth <= 0) { return false; } myIsDirty = false; recalculateSoftWraps(new IncrementalCacheUpdateEvent(myEditor.getDocument())); onRecalculationEnd(); return true; } private void onRecalculationEnd() { updateLastTopLeftCornerOffset(); for (SoftWrapAwareDocumentParsingListener listener : myListeners) { listener.recalculationEnds(); } } private void recalculateSoftWraps(@NotNull IncrementalCacheUpdateEvent event) { if (myEditor.getDocument() instanceof DocumentImpl && ((DocumentImpl)myEditor.getDocument()).acceptsSlashR()) { LOG.error("Soft wrapping is not supported for documents with non-standard line endings. File: " + myEditor.getVirtualFile()); } if (myInProgress) { LogMessageEx.error(LOG, "Detected race condition at soft wraps recalculation", myEditor.dumpState(), event.toString()); } myInProgress = true; try { doRecalculateSoftWraps(event); } finally { myInProgress = false; } } private void doRecalculateSoftWraps(IncrementalCacheUpdateEvent event) { myEventBeingProcessed = event; notifyListenersOnCacheUpdateStart(event); // Preparation. myContext.reset(); myOffset2fontType.clear(); myOffset2widthInPixels.clear(); EditorTextRepresentationHelper editorTextRepresentationHelper = SoftWrapModelImpl.getEditorTextRepresentationHelper(myEditor); if (editorTextRepresentationHelper instanceof DefaultEditorTextRepresentationHelper) { ((DefaultEditorTextRepresentationHelper)editorTextRepresentationHelper).updateContext(); } // Define start of the visual line that holds target range start. final int start = event.getStartOffset(); final LogicalPosition logical = event.getStartLogicalPosition(); int endOffsetUpperEstimate = getEndOffsetUpperEstimate(event); Document document = myEditor.getDocument(); myContext.text = document.getCharsSequence(); myContext.tokenStartOffset = start; IterationState iterationState = new IterationState(myEditor, start, document.getTextLength(), null, false, false, true, false); TextAttributes attributes = iterationState.getMergedAttributes(); myContext.fontType = attributes.getFontType(); myContext.rangeEndOffset = event.getMandatoryEndOffset(); EditorPosition position = new EditorPosition(logical, start, myEditor); position.x = start == 0 ? myEditor.getPrefixTextWidthInPixels() : 0; int spaceWidth = EditorUtil.getSpaceWidth(myContext.fontType, myEditor); int plainSpaceWidth = EditorUtil.getSpaceWidth(Font.PLAIN, myEditor); myContext.logicalLineData.update(logical.line, spaceWidth, plainSpaceWidth); myContext.currentPosition = position; myContext.lineStartPosition = position.clone(); myContext.fontType2spaceWidth.put(myContext.fontType, spaceWidth); myContext.softWrapStartOffset = position.offset; myContext.reservedWidthInPixels = myPainter.getMinDrawingWidth(SoftWrapDrawingType.BEFORE_SOFT_WRAP_LINE_FEED); SoftWrap softWrapAtStartPosition = myStorage.getSoftWrap(start); if (softWrapAtStartPosition != null) { myContext.currentPosition.x = softWrapAtStartPosition.getIndentInPixels(); myContext.softWrapStartOffset++; } myContext.inlays = myEditor.getInlayModel().getInlineElementsInRange(start, endOffsetUpperEstimate); // Perform soft wraps calculation. while (!iterationState.atEnd()) { FoldRegion currentFold = iterationState.getCurrentFold(); if (currentFold == null) { myContext.tokenEndOffset = iterationState.getEndOffset(); myContext.nextIsFoldRegion = iterationState.nextIsFoldRegion(); if (processNonFoldToken()) { break; } } else { if (processCollapsedFoldRegion(currentFold)) { break; } // 'myOffset2widthInPixels' contains information necessary to processing soft wraps that lay before the current offset. // We do know that soft wraps are not allowed to go backward after processed collapsed fold region, hence, we drop // information about processed symbols width. myOffset2widthInPixels.clear(); } iterationState.advance(); attributes = iterationState.getMergedAttributes(); myContext.fontType = attributes.getFontType(); myContext.tokenStartOffset = iterationState.getStartOffset(); myOffset2fontType.fill(myContext.tokenStartOffset, iterationState.getEndOffset(), myContext.fontType); } if (myContext.delayedSoftWrap != null) { myStorage.remove(myContext.delayedSoftWrap); } event.setActualEndOffset(myContext.currentPosition.offset); if (LOG.isDebugEnabled()) { LOG.debug("Soft wrap recalculation done: " + event.toString() + ". " + (event.getActualEndOffset() - event.getStartOffset()) + " characters processed"); } if (event.getActualEndOffset() > endOffsetUpperEstimate) { LOG.error("Unexpected error at soft wrap recalculation", new Attachment("softWrapModel.txt", myEditor.getSoftWrapModel().toString())); } notifyListenersOnCacheUpdateEnd(event); myEventBeingProcessed = null; } private int getEndOffsetUpperEstimate(IncrementalCacheUpdateEvent event) { int endOffsetUpperEstimate = EditorUtil.getNotFoldedLineEndOffset(myEditor, event.getMandatoryEndOffset()); int line = myEditor.getDocument().getLineNumber(endOffsetUpperEstimate); if (line < myEditor.getDocument().getLineCount() - 1) { endOffsetUpperEstimate = myEditor.getDocument().getLineStartOffset(line + 1); } return endOffsetUpperEstimate; } /** * Encapsulates logic of processing given collapsed fold region. * * @param foldRegion target collapsed fold region to process * @return <code>true</code> if no further calculation is required */ private boolean processCollapsedFoldRegion(FoldRegion foldRegion) { Document document = myEditor.getDocument(); if (!foldRegion.isValid() || foldRegion.getStartOffset() != myContext.tokenStartOffset || foldRegion.getEndOffset() > document.getTextLength()) { LOG.error("Inconsistent fold region state: fold region: " + foldRegion + ", soft wrap model state: " + myEditor.getSoftWrapModel() + ", folding model state: " + myEditor.getFoldingModel()); return true; } String placeholder = foldRegion.getPlaceholderText(); int placeholderWidthInPixels = 0; for (int i = 0; i < placeholder.length(); i++) { char c = placeholder.charAt(i); if (c == '\n') c = ' '; // we display \n as space (see com.intellij.openapi.editor.impl.view.EditorView.getFoldRegionLayout) placeholderWidthInPixels += SoftWrapModelImpl.getEditorTextRepresentationHelper(myEditor) .charWidth(c, myContext.fontType); } if (myContext.delayedSoftWrap == null) { int newX = myContext.currentPosition.x + placeholderWidthInPixels; if (!myContext.exceedsVisualEdge(newX) || myContext.currentPosition.offset == myContext.lineStartPosition.offset) { myContext.advance(foldRegion, placeholderWidthInPixels); return false; } } myContext.logicalLineData.update(foldRegion.getStartOffset()); SoftWrap softWrap = null; if (myContext.delayedSoftWrap == null && myContext.exceedsVisualEdge(myContext.currentPosition.x + myContext.reservedWidthInPixels)) { softWrap = registerSoftWrap( myContext.softWrapStartOffset, myContext.tokenStartOffset, myContext.tokenStartOffset, myContext.getSpaceWidth(), myContext.logicalLineData ); } if (myContext.delayedSoftWrap != null) { myStorage.remove(myContext.delayedSoftWrap); myContext.delayedSoftWrap = null; } if (softWrap == null) { // If we're here that means that we can't find appropriate soft wrap offset before the fold region. // However, we expect that it's always possible to wrap collapsed fold region placeholder text softWrap = registerSoftWrap(foldRegion.getStartOffset(), myContext.getSpaceWidth(), myContext.logicalLineData); } myContext.softWrapStartOffset = softWrap.getStart(); if (softWrap.getStart() < myContext.tokenStartOffset) { for (int j = foldRegion.getStartOffset() - 1; j >= softWrap.getStart(); j--) { myContext.currentPosition.offset--; } } myContext.currentPosition.x = softWrap.getIndentInPixels(); myContext.clearLastFoldInfo(); myContext.skipToLineEnd = false; if (checkIsDoneAfterSoftWrap()) { return true; } while (myContext.currentPosition.offset < myContext.tokenStartOffset) { int c = Character.codePointAt(myContext.text, myContext.currentPosition.offset); myContext.onNonLineFeedSymbol(c, calculateNewX(c)); } myOffset2fontType.clear(); myContext.advance(foldRegion, placeholderWidthInPixels); return false; } /** * Encapsulates logic of processing target non-fold region token defined by the {@link #myContext current processing context} * (target token start offset is identified by {@link ProcessingContext#tokenStartOffset}; end offset is stored * at {@link ProcessingContext#tokenEndOffset}). * <p/> * <code>'Token'</code> here stands for the number of subsequent symbols that are represented using the same font by IJ editor. * * @return <code>true</code> if no further calculation is required */ private boolean processNonFoldToken() { int limit = 3 * (myContext.tokenEndOffset - myContext.lineStartPosition.offset); int counter = 0; int startOffset = myContext.currentPosition.offset; while (myContext.currentPosition.offset < myContext.tokenEndOffset) { if (counter++ > limit) { LogMessageEx.error(LOG, "Cycled soft wraps recalculation detected", String.format( "Start recalculation offset: %d, visible area width: %d, calculation context: %s, editor info: %s", startOffset, myVisibleAreaWidth, myContext, myEditor.dumpState())); while (myContext.currentPosition.offset < myContext.tokenEndOffset) { int c = Character.codePointAt(myContext.text, myContext.currentPosition.offset); if (c == '\n') { myContext.onNewLine(); if (checkIsDoneAfterNewLine()) { return true; } } else { myContext.onNonLineFeedSymbol(c); } } return false; } int offset = myContext.currentPosition.offset; if (myContext.delayedSoftWrap != null && myContext.delayedSoftWrap.getStart() == offset) { processSoftWrap(myContext.delayedSoftWrap); myContext.delayedSoftWrap = null; if (checkIsDoneAfterSoftWrap()) { return true; } } int c = Character.codePointAt(myContext.text, offset); if (c == '\n') { myContext.onNewLine(); if (checkIsDoneAfterNewLine()) { return true; } continue; } if (myContext.skipToLineEnd) { myContext.skipToLineEnd = false; // Assuming that this flag is set if no soft wrap is registered during processing the call below if (createSoftWrapIfPossible()) { return true; } continue; } int[] metrics = offsetToX(offset, c); if (myContext.exceedsVisualEdge(metrics[0]) && myContext.delayedSoftWrap == null) { if (createSoftWrapIfPossible()) { return true; } } else { myContext.onNonLineFeedSymbol(c, metrics); } } return false; } private boolean checkIsDoneAfterNewLine() { return myContext.currentPosition.offset > myContext.rangeEndOffset; } private boolean checkIsDoneAfterSoftWrap() { SoftWrapImpl lastSoftWrap = myDataMapper.getLastSoftWrap(); LOG.assertTrue(lastSoftWrap != null); return myContext.currentPosition.offset > myContext.rangeEndOffset && myDataMapper.matchesOldSoftWrap(lastSoftWrap, myEventBeingProcessed.getLengthDiff()); } /** * Allows to retrieve 'x' coordinate of the right edge of document symbol referenced by the given offset. * * @param offset target symbol offset * @param c target symbol referenced by the given offset * @return 'x' coordinate of the right edge of document symbol referenced by the given offset */ private int[] offsetToX(int offset, int c) { if (myOffset2widthInPixels.end > offset && (myOffset2widthInPixels.anchor + myOffset2widthInPixels.end > offset)) { int width = myOffset2widthInPixels.data[offset - myOffset2widthInPixels.anchor]; return new int[] {myContext.currentPosition.x + width + myContext.getInlaysWidth(), width}; } else { return calculateNewX(c); } } private boolean createSoftWrapIfPossible() { final int offset = myContext.currentPosition.offset; myContext.logicalLineData.update(offset); int softWrapStartOffset = myContext.softWrapStartOffset; int preferredOffset = Math.max(softWrapStartOffset, offset - 1 /* reserve a column for the soft wrap sign */); SoftWrapImpl softWrap = registerSoftWrap( softWrapStartOffset, preferredOffset, myContext.logicalLineData.endLineOffset, myContext.getSpaceWidth(), myContext.logicalLineData ); FoldRegion revertedToFoldRegion = null; if (softWrap == null) { EditorPosition wrapPosition = null; // Try to insert soft wrap after the last collapsed fold region that is located on the current visual line. if (myContext.lastFoldEndPosition != null && myStorage.getSoftWrap(myContext.lastFoldEndPosition.offset) == null) { wrapPosition = myContext.lastFoldEndPosition; } if (wrapPosition == null && myContext.lastFoldStartPosition != null && myStorage.getSoftWrap(myContext.lastFoldStartPosition.offset) == null && myContext.lastFoldStartPosition.offset < myContext.currentPosition.offset) { wrapPosition = myContext.lastFoldStartPosition; } if (wrapPosition != null){ myContext.currentPosition = wrapPosition; softWrap = registerSoftWrap(wrapPosition.offset, myContext.getSpaceWidth(), myContext.logicalLineData); myContext.tokenStartOffset = wrapPosition.offset; revertedToFoldRegion = myContext.lastFold; } else { return myContext.tryToShiftToNextLine(); } } myContext.skipToLineEnd = false; int actualSoftWrapOffset = softWrap.getStart(); // There are three possible options: // 1. Soft wrap offset is located before the current offset; // 2. Soft wrap offset is located after the current offset but doesn't exceed current token end offset // (it may occur if there are no convenient wrap positions before the current offset); // 3. Soft wrap offset is located after the current offset and exceeds current token end offset; // We should process that accordingly. if (actualSoftWrapOffset > myContext.tokenEndOffset) { myContext.delayedSoftWrap = softWrap; myContext.onNonLineFeedSymbol(Character.codePointAt(myContext.text, offset)); return false; } else if (actualSoftWrapOffset < offset) { if (revertedToFoldRegion == null) { while (myContext.currentPosition.offset > actualSoftWrapOffset) { int prevOffset = Character.offsetByCodePoints(myContext.text, myContext.currentPosition.offset, -1); int pixelsDiff = myOffset2widthInPixels.data[prevOffset - myOffset2widthInPixels.anchor]; myContext.currentPosition.offset = prevOffset; myContext.currentPosition.x -= pixelsDiff; } } } else if (actualSoftWrapOffset > offset) { while (myContext.currentPosition.offset < actualSoftWrapOffset) { myContext.onNonLineFeedSymbol(Character.codePointAt(myContext.text, myContext.currentPosition.offset)); } } processSoftWrap(softWrap); myContext.currentPosition.offset = actualSoftWrapOffset; myOffset2fontType.clear(); myOffset2widthInPixels.clear(); if (checkIsDoneAfterSoftWrap()) { return true; } if (revertedToFoldRegion != null && myContext.currentPosition.offset == revertedToFoldRegion.getStartOffset()) { return processCollapsedFoldRegion(revertedToFoldRegion); } return false; } // {newX, actualWidth} private int[] calculateNewX(int c) { if (c == '\t') { int xStart = myContext.currentPosition.x + myContext.getInlaysPrefixWidth(); int xEnd = EditorUtil.nextTabStop(xStart, myEditor); return new int[] {xEnd + myContext.getInlaysSuffixWidth(), xEnd - xStart}; } else { int width = SoftWrapModelImpl.getEditorTextRepresentationHelper(myEditor).charWidth(c, myContext.fontType); return new int[] {myContext.currentPosition.x + width + myContext.getInlaysWidth(), width}; } } private static int calculateWidthInColumns(char c, int widthInPixels, int plainSpaceWithInPixels) { if (c != '\t') { return 1; } int result = widthInPixels / plainSpaceWithInPixels; if (widthInPixels % plainSpaceWithInPixels > 0) { result++; } return result; } /** * This method is assumed to be called in a situation when visible area width is exceeded. It tries to create and register * new soft wrap which data is defined in accordance with the given parameters. * <p/> * There is a possible case that no soft wrap is created and registered. That is true, for example, for a situation when * we have a long line of text that doesn't contain white spaces, operators or any other symbols that may be used * as a <code>'wrap points'</code>. We just left such lines as-is. * * @param minOffset min line <code>'wrap point'</code> offset * @param preferredOffset preferred <code>'wrap point'</code> offset, i.e. max offset which symbol doesn't exceed right margin * @param maxOffset max line <code>'wrap point'</code> offset * @param spaceSize current space width in pixels * @param lineData object that encapsulates information about currently processed logical line * @return newly created and registered soft wrap if any; <code>null</code> otherwise */ @Nullable private SoftWrapImpl registerSoftWrap(int minOffset, int preferredOffset, int maxOffset, int spaceSize, LogicalLineData lineData) { int softWrapOffset = calculateBackwardSpaceOffsetIfPossible(minOffset, preferredOffset); if (softWrapOffset < 0) { softWrapOffset = calculateBackwardOffsetForEasternLanguageIfPossible(minOffset, preferredOffset); } if (softWrapOffset < 0) { Document document = myEditor.getDocument(); // Performance optimization implied by profiling results analysis. if (myLineWrapPositionStrategy == null) { myLineWrapPositionStrategy = LanguageLineWrapPositionStrategy.INSTANCE.forEditor(myEditor); } softWrapOffset = myLineWrapPositionStrategy.calculateWrapPosition( document, myEditor.getProject(), minOffset, maxOffset, preferredOffset, true, true ); if (DocumentUtil.isInsideSurrogatePair(document, softWrapOffset)) softWrapOffset--; } if (softWrapOffset >= lineData.endLineOffset || softWrapOffset < 0 || softWrapOffset <= minOffset || (myCustomIndentUsedLastTime && softWrapOffset == lineData.nonWhiteSpaceSymbolOffset) || (softWrapOffset > preferredOffset && myContext.lastFoldStartPosition != null // Prefer to wrap on fold region backwards && myContext.lastFoldStartPosition.offset <= preferredOffset)) // to wrapping forwards. { return null; } return registerSoftWrap(softWrapOffset, spaceSize, lineData); } @NotNull private SoftWrapImpl registerSoftWrap(int offset, int spaceSize, LogicalLineData lineData) { assert !DocumentUtil.isInsideSurrogatePair(myEditor.getDocument(), offset); int indentInColumns = 0; int indentInPixels = myPainter.getMinDrawingWidth(SoftWrapDrawingType.AFTER_SOFT_WRAP); if (myCustomIndentUsedLastTime) { indentInColumns = myCustomIndentValueUsedLastTime + lineData.indentInColumns; indentInPixels += lineData.indentInPixels + (myCustomIndentValueUsedLastTime * spaceSize); } SoftWrapImpl result = new SoftWrapImpl( new TextChangeImpl("\n" + StringUtil.repeatSymbol(' ', indentInColumns), offset, offset), indentInColumns + 1/* for 'after soft wrap' drawing */, indentInPixels ); myStorage.storeOrReplace(result); return result; } /** * It was found out that frequent soft wrap position calculation may become performance bottleneck (e.g. consider application * that is run under IJ and writes long strings to stdout non-stop. If those strings are long enough to be soft-wrapped, * we have the mentioned situation). * <p/> * Hence, we introduce an optimization here - try to find offset of white space symbol that belongs to the target interval and * use its offset as soft wrap position. * * @param minOffset min offset to use (inclusive) * @param preferredOffset max offset to use (inclusive) * @return offset of the space symbol that belongs to <code>[minOffset; preferredOffset]</code> interval if any; * <code>'-1'</code> otherwise */ private int calculateBackwardSpaceOffsetIfPossible(int minOffset, int preferredOffset) { // There is a possible case that we have a long line that contains many non-white space symbols eligible for performing // soft wrap that are preceded by white space symbol. We don't want to create soft wrap that is located so far from the // preferred position then, hence, we check white space symbol existence not more than specific number of symbols back. int maxTrackBackSymbolsNumber = 10; int minOffsetToUse = minOffset; if (preferredOffset - minOffset > maxTrackBackSymbolsNumber) { minOffsetToUse = preferredOffset - maxTrackBackSymbolsNumber; } for (int i = preferredOffset - 1; i >= minOffsetToUse; i--) { char c = myContext.text.charAt(i); if (c == ' ') { return i + 1; } } return -1; } /** * There is a possible case that current line holds eastern language symbols (e.g. japanese text). We want to allow soft * wrap just after such symbols and this method encapsulates the logic that tries to calculate soft wraps offset on that basis. * * @param minOffset min offset to use (inclusive) * @param preferredOffset max offset to use (inclusive) * @return soft wrap offset that belongs to <code>[minOffset; preferredOffset]</code> interval if any; * <code>'-1'</code> otherwise */ public int calculateBackwardOffsetForEasternLanguageIfPossible(int minOffset, int preferredOffset) { // There is a possible case that we have a long line that contains many non-white space symbols eligible for performing // soft wrap that are preceded by white space symbol. We don't want to create soft wrap that is located so far from the // preferred position then, hence, we check white space symbol existence not more than specific number of symbols back. int maxTrackBackSymbolsNumber = 10; int minOffsetToUse = minOffset; if (preferredOffset - minOffset > maxTrackBackSymbolsNumber) { minOffsetToUse = preferredOffset - maxTrackBackSymbolsNumber; } for (int i = preferredOffset - 1; i >= minOffsetToUse; i--) { char c = myContext.text.charAt(i); // Check this document for eastern languages unicode ranges - http://www.unicode.org/charts if (c >= 0x2f00 && Character.isBmpCodePoint(Character.codePointAt(myContext.text, i))) { return i + 1; } } return -1; } private void processSoftWrap(SoftWrap softWrap) { EditorPosition position = myContext.currentPosition; myContext.lineStartPosition.from(myContext.currentPosition); position.x = softWrap.getIndentInPixels(); myContext.softWrapStartOffset = softWrap.getStart() + 1; myContext.clearLastFoldInfo(); } /** * There is a possible case that we need to reparse the whole document (e.g. visible area width is changed or user-defined * soft wrap indent is changed etc). This method encapsulates that logic, i.e. it checks if necessary conditions are satisfied * and updates internal state as necessary. * * @return <code>true</code> if re-calculation logic was performed; * <code>false</code> otherwise (e.g. we need to perform re-calculation but current editor is now shown, i.e. we don't * have information about viewport width */ public boolean recalculateIfNecessary() { if (myInProgress) { return false; } // Check if we need to recalculate soft wraps due to indent settings change. boolean indentChanged = false; IndentType currentIndentType = getIndentToUse(); boolean useCustomIndent = currentIndentType == IndentType.CUSTOM; int currentCustomIndent = myEditor.getSettings().getCustomSoftWrapIndent(); if (useCustomIndent ^ myCustomIndentUsedLastTime || (useCustomIndent && myCustomIndentValueUsedLastTime != currentCustomIndent)) { indentChanged = true; } myCustomIndentUsedLastTime = useCustomIndent; myCustomIndentValueUsedLastTime = currentCustomIndent; // Check if we need to recalculate soft wraps due to visible area width change. int currentVisibleAreaWidth = myAvailableArea.width; if (!indentChanged && myVisibleAreaWidth == currentVisibleAreaWidth) { return recalculateSoftWraps(); // Recalculate existing dirty regions if any. } final JScrollBar scrollBar = myEditor.getScrollPane().getVerticalScrollBar(); if (myVerticalScrollBarWidth < 0) { myVerticalScrollBarWidth = scrollBar.getWidth(); if (myVerticalScrollBarWidth <= 0) { myVerticalScrollBarWidth = scrollBar.getPreferredSize().width; } } // We experienced the following situation: // 1. Editor is configured to show scroll bars only when necessary; // 2. Editor with active soft wraps is changed in order for the vertical scroll bar to appear; // 3. Vertical scrollbar consumes vertical space, hence, soft wraps are recalculated because of the visual area width change; // 4. Newly recalculated soft wraps trigger editor size update; // 5. Editor size update starts scroll pane update which, in turn, disables vertical scroll bar at first (the reason for that // lays somewhere at the swing depth); // 6. Soft wraps are recalculated because of visible area width change caused by the disabled vertical scroll bar; // 7. Go to the step 4; // I.e. we have an endless EDT activity that stops only when editor is re-sized in a way to avoid vertical scroll bar. // That's why we don't recalculate soft wraps when visual area width is changed to the vertical scroll bar width value assuming // that such a situation is triggered by the scroll bar (dis)appearance. if (Math.abs(currentVisibleAreaWidth - myVisibleAreaWidth) == myVerticalScrollBarWidth) { myVisibleAreaWidth = currentVisibleAreaWidth; return recalculateSoftWraps(); } // We want to adjust viewport's 'y' coordinate on complete recalculation, so, we remember number of soft-wrapped lines // before the target offset on recalculation start and compare it with the number of soft-wrapped lines before the same offset // after the recalculation. int softWrapsBefore = -1; final ScrollingModelEx scrollingModel = myEditor.getScrollingModel(); int yScrollOffset = scrollingModel.getVerticalScrollOffset(); int anchorOffset = myLastTopLeftCornerOffset; if (anchorOffset >= 0) { softWrapsBefore = getNumberOfSoftWrapsBefore(anchorOffset); } // Drop information about processed lines. reset(); myStorage.removeAll(); myVisibleAreaWidth = currentVisibleAreaWidth; final boolean result = recalculateSoftWraps(); if (!result) { return false; } // Adjust viewport's 'y' coordinate if necessary. if (softWrapsBefore >= 0) { int softWrapsNow = getNumberOfSoftWrapsBefore(anchorOffset); if (softWrapsNow != softWrapsBefore) { scrollingModel.disableAnimation(); try { scrollingModel.scrollVertically(yScrollOffset + (softWrapsNow - softWrapsBefore) * myEditor.getLineHeight()); } finally { scrollingModel.enableAnimation(); } } } updateLastTopLeftCornerOffset(); return true; } private void updateLastTopLeftCornerOffset() { int visualLine = 1 + myEditor.getScrollingModel().getVisibleArea().y / myEditor.getLineHeight(); myLastTopLeftCornerOffset = myEditor.visualLineStartOffset(visualLine); } private int getNumberOfSoftWrapsBefore(int offset) { final int i = myStorage.getSoftWrapIndex(offset); return i >= 0 ? i : -i - 1; } private IndentType getIndentToUse() { return myEditor.getSettings().isUseCustomSoftWrapIndent() ? IndentType.CUSTOM : IndentType.NONE; } /** * Registers given listener within the current manager. * * @param listener listener to register * @return <code>true</code> if this collection changed as a result of the call; <code>false</code> otherwise */ public boolean addListener(@NotNull SoftWrapAwareDocumentParsingListener listener) { return myListeners.add(listener); } public boolean removeListener(@NotNull SoftWrapAwareDocumentParsingListener listener) { return myListeners.remove(listener); } @SuppressWarnings({"ForLoopReplaceableByForEach"}) private void notifyListenersOnCacheUpdateStart(IncrementalCacheUpdateEvent event) { for (int i = 0; i < myListeners.size(); i++) { // Avoid unnecessary Iterator object construction as this method is expected to be called frequently. SoftWrapAwareDocumentParsingListener listener = myListeners.get(i); listener.onCacheUpdateStart(event); } } @SuppressWarnings({"ForLoopReplaceableByForEach"}) private void notifyListenersOnCacheUpdateEnd(IncrementalCacheUpdateEvent event) { for (int i = 0; i < myListeners.size(); i++) { // Avoid unnecessary Iterator object construction as this method is expected to be called frequently. SoftWrapAwareDocumentParsingListener listener = myListeners.get(i); listener.onRecalculationEnd(event); } } public void beforeDocumentChange(DocumentEvent event) { myDocumentChangedEvent = new IncrementalCacheUpdateEvent(event, myEditor); } public void documentChanged(DocumentEvent event) { LOG.assertTrue(myDocumentChangedEvent != null); myDocumentChangedEvent.updateAfterDocumentChange(event.getDocument()); recalculate(myDocumentChangedEvent); myDocumentChangedEvent = null; } public void setWidthProvider(@NotNull VisibleAreaWidthProvider widthProvider) { myWidthProvider = widthProvider; reset(); } @NotNull @Override public String dumpState() { return String.format( "recalculation in progress: %b; event being processed: %s, available area: %s, visible width: %d, dirty: %b", myInProgress, myEventBeingProcessed, myAvailableArea.toString(), myVisibleAreaWidth, myIsDirty ); } @Override public String toString() { return dumpState(); } @TestOnly public void setSoftWrapPainter(SoftWrapPainter painter) { myPainter = painter; } @NotNull public Rectangle getAvailableArea() { return myAvailableArea; } public void updateAvailableArea() { Rectangle visibleArea = myEditor.getScrollingModel().getVisibleArea(); myAvailableArea.setSize(myWidthProvider.getVisibleAreaWidth(), visibleArea.height); } /** * We need to use correct indent for soft-wrapped lines, i.e. they should be indented to the start of the logical line. * This class stores information about logical line start indent. */ private class LogicalLineData { public int indentInColumns; public int indentInPixels; public int endLineOffset; public int nonWhiteSpaceSymbolOffset; public void update(int logicalLine, int spaceWidth, int plainSpaceWidth) { Document document = myEditor.getDocument(); int startLineOffset; if (logicalLine >= document.getLineCount()) { startLineOffset = endLineOffset = document.getTextLength(); } else { startLineOffset = document.getLineStartOffset(logicalLine); endLineOffset = document.getLineEndOffset(logicalLine); } CharSequence text = document.getCharsSequence(); indentInColumns = 0; indentInPixels = 0; nonWhiteSpaceSymbolOffset = -1; for (int i = startLineOffset; i < endLineOffset; i++) { char c = text.charAt(i); switch (c) { case ' ': indentInColumns += 1; indentInPixels += spaceWidth; break; case '\t': int x = EditorUtil.nextTabStop(indentInPixels, myEditor); indentInColumns += calculateWidthInColumns(c, x - indentInPixels, plainSpaceWidth); indentInPixels = x; break; default: nonWhiteSpaceSymbolOffset = i; return; } } } /** * There is a possible case that all document line symbols before the first soft wrap are white spaces. We don't want to use * such a big indent then. * <p/> * This method encapsulates logic that 'resets' indent to use if such a situation is detected. * * @param softWrapOffset offset of the soft wrap that occurred on document line which data is stored at the current object */ public void update(int softWrapOffset) { if (nonWhiteSpaceSymbolOffset >= 0 && softWrapOffset > nonWhiteSpaceSymbolOffset) { return; } indentInColumns = 0; indentInPixels = 0; } public void reset() { indentInColumns = 0; indentInPixels = 0; endLineOffset = 0; } } /** * This interface is introduced mostly for encapsulating GUI-specific values retrieval and make it possible to write * tests for soft wraps processing. */ public interface VisibleAreaWidthProvider { int getVisibleAreaWidth(); } private static class DefaultVisibleAreaWidthProvider implements VisibleAreaWidthProvider { private final EditorImpl myEditor; DefaultVisibleAreaWidthProvider(EditorImpl editor) { myEditor = editor; } @Override public int getVisibleAreaWidth() { Insets insets = myEditor.getContentComponent().getInsets(); int width = Math.max(0, myEditor.getScrollingModel().getVisibleArea().width - insets.left - insets.right); if (myEditor.isInDistractionFreeMode()) { int rightMargin = myEditor.getSettings().getRightMargin(myEditor.getProject()); if (rightMargin > 0) width = Math.min(width, rightMargin * EditorUtil.getPlainSpaceWidth(myEditor)); } return width; } } /** * Primitive array-based data structure that contain mappings like {@code int -> int}. * <p/> * The key is array index plus anchor; the value is array value. */ private static class WidthsStorage { public int[] data = new int[256]; public int anchor; public int end; public void clear() { anchor = 0; end = 0; } } /** * * We need to be able to track back font types to offsets mappings because text processing may be shifted back because of soft wrap. * <p/> * <b>Example</b> * Suppose with have this line of text that should be soft-wrapped * <pre> * | <- right margin * token1 token2-toke|n3 * | <- right margin * </pre> * It's possible that <code>'token1'</code>, white spaces and <code>'token2'</code> use different font types and * soft wrapping should be performed between <code>'token1'</code> and <code>'token2'</code>. We need to be able to * match offsets of <code>'token2'</code> to font types then. * <p/> * There is an additional trick here - there is a possible case that a bunch number of adjacent symbols use the same font * type (are marked by {@link IterationState} as a single token. That is often the case for plain text). We don't want to * store those huge mappings then (it may take over million records) because it's indicated by profiling as extremely expensive * and causing unnecessary garbage collections that dramatically reduce overall application throughput. * <p/> * Hence, we want to restrict ourselves by storing information about particular sub-sequence of overall token offsets. * <p/> * This is primitive array-based data structure that contains {@code offset -> font type} mappings. */ private static class FontTypesStorage { private int[] myStarts = new int[256]; private int[] myEnds = new int[256]; private int[] myData = new int[256]; private int myLastIndex = -1; public void fill(int start, int end, int value) { if (myLastIndex >= 0 && myData[myLastIndex] == value && myEnds[myLastIndex] == start) { myEnds[myLastIndex] = end; return; } if (++myLastIndex >= myData.length) { expand(); } myStarts[myLastIndex] = start; myEnds[myLastIndex] = end; myData[myLastIndex] = value; } /** * Tries to retrieve stored value for the given offset if any; * * @param offset target offset * @return target value if any is stored; <code>-1</code> otherwise */ public int get(int offset) { // The key is array index plus anchor; the value is array value. if (myLastIndex < 0) { return -1; } for (int i = myLastIndex; i >= 0 && myEnds[i] >= offset; i--) { if (myStarts[i] <= offset) { return myData[i]; } } return -1; } public void clear() { myLastIndex = -1; } private void expand() { int[] tmp = new int[myStarts.length * 2]; System.arraycopy(myStarts, 0, tmp, 0, myStarts.length); myStarts = tmp; tmp = new int[myEnds.length * 2]; System.arraycopy(myEnds, 0, tmp, 0, myEnds.length); myEnds = tmp; tmp = new int[myData.length * 2]; System.arraycopy(myData, 0, tmp, 0, myData.length); myData = tmp; } } private class ProcessingContext { public final PrimitiveIntMap fontType2spaceWidth = new PrimitiveIntMap(); public final LogicalLineData logicalLineData = new LogicalLineData(); public CharSequence text; public EditorPosition lineStartPosition; public EditorPosition currentPosition; /** * Start position of the last collapsed fold region that is located at the current visual line and can be used as a fall back * position for soft wrapping. */ public EditorPosition lastFoldStartPosition; public EditorPosition lastFoldEndPosition; /** A fold region referenced by the {@link #lastFoldStartPosition}. */ public FoldRegion lastFold; public SoftWrapImpl delayedSoftWrap; public int reservedWidthInPixels; /** * Min offset to use when new soft wrap should be introduced. I.e. every time we detect that text exceeds visual width, */ public int softWrapStartOffset; public int rangeEndOffset; public int tokenStartOffset; public int tokenEndOffset; public boolean nextIsFoldRegion; @JdkConstants.FontStyle public int fontType; public boolean skipToLineEnd; public List<Inlay> inlays; public int inlayIndex; @Override public String toString() { return "reserved width: " + reservedWidthInPixels + ", soft wrap start offset: " + softWrapStartOffset + ", range end offset: " + rangeEndOffset + ", token offsets: [" + tokenStartOffset + "; " + tokenEndOffset + "], font type: " + fontType + ", skip to line end: " + skipToLineEnd + ", delayed soft wrap: " + delayedSoftWrap + ", current position: "+ currentPosition + "line start position: " + lineStartPosition; } public void reset() { text = null; lineStartPosition = null; currentPosition = null; clearLastFoldInfo(); delayedSoftWrap = null; reservedWidthInPixels = 0; softWrapStartOffset = 0; rangeEndOffset = 0; tokenStartOffset = 0; tokenEndOffset = 0; nextIsFoldRegion = false; fontType = Font.PLAIN; skipToLineEnd = false; fontType2spaceWidth.reset(); logicalLineData.reset(); inlays = null; inlayIndex = 0; } public int getSpaceWidth() { return getSpaceWidth(fontType); } public int getPlainSpaceWidth() { return getSpaceWidth(Font.PLAIN); } private int getSpaceWidth(@JdkConstants.FontStyle int fontType) { int result = fontType2spaceWidth.get(fontType); if (result <= 0) { result = EditorUtil.getSpaceWidth(fontType, myEditor); fontType2spaceWidth.put(fontType, result); } assert result > 0; return result; } /** * Asks current context to update its state assuming that it begins to point to the line next to its current position. */ @SuppressWarnings("MagicConstant") public void onNewLine() { currentPosition.onNewLine(); softWrapStartOffset = currentPosition.offset; clearLastFoldInfo(); lineStartPosition.from(currentPosition); logicalLineData.update(currentPosition.logicalLine, getSpaceWidth(), getPlainSpaceWidth()); fontType = myOffset2fontType.get(currentPosition.offset); myOffset2fontType.clear(); myOffset2widthInPixels.clear(); skipToLineEnd = false; } private void clearLastFoldInfo() { lastFoldStartPosition = null; lastFoldEndPosition = null; lastFold = null; } public void onNonLineFeedSymbol(int c) { int[] metrics; if (myOffset2widthInPixels.end > myContext.currentPosition.offset && (myOffset2widthInPixels.anchor + myOffset2widthInPixels.end > myContext.currentPosition.offset)) { int width = myOffset2widthInPixels.data[myContext.currentPosition.offset - myOffset2widthInPixels.anchor]; metrics = new int[] {myContext.currentPosition.x + width + getInlaysWidth(), width}; } else { metrics = calculateNewX(c); } onNonLineFeedSymbol(c, metrics); } @SuppressWarnings("MagicConstant") public void onNonLineFeedSymbol(int codePoint, int[] metrics) { // {newX, actualWidth} if (myOffset2widthInPixels.anchor <= 0) { myOffset2widthInPixels.anchor = currentPosition.offset; } if (currentPosition.offset - myOffset2widthInPixels.anchor >= myOffset2widthInPixels.data.length) { int newLength = Math.max(myOffset2widthInPixels.data.length * 2, currentPosition.offset - myOffset2widthInPixels.anchor + 1); int[] newData = new int[newLength]; System.arraycopy(myOffset2widthInPixels.data, 0, newData, 0, myOffset2widthInPixels.data.length); myOffset2widthInPixels.data = newData; } myOffset2widthInPixels.data[currentPosition.offset - myOffset2widthInPixels.anchor] = metrics[1]; myOffset2widthInPixels.end++; currentPosition.x = metrics[0]; currentPosition.offset += Character.isBmpCodePoint(codePoint) ? 1 : 2; fontType = myOffset2fontType.get(currentPosition.offset); } private int getInlaysWidth() { return getInlaysPrefixWidth() + getInlaysSuffixWidth(); } private int getInlaysPrefixWidth() { return getInlaysPrefixWidthForOffset(currentPosition.offset); } private int getInlaysPrefixWidthForOffset(int offset) { while (inlayIndex < inlays.size() && inlays.get(inlayIndex).getOffset() < offset) inlayIndex++; while (inlayIndex > 0 && inlays.get(inlayIndex - 1).getOffset() >= offset) inlayIndex--; int width = 0; while (inlayIndex < inlays.size() && inlays.get(inlayIndex).getOffset() == offset) { width += inlays.get(inlayIndex++).getWidthInPixels(); } return width; } private int getInlaysSuffixWidth() { int offset = currentPosition.offset; return offset < text.length() && text.charAt(offset) != '\n' || offset > tokenEndOffset || offset == tokenEndOffset && nextIsFoldRegion ? 0 : getInlaysPrefixWidthForOffset(offset + 1); } /** * Updates state of the current context object in order to point to the end of the given collapsed fold region. * * @param foldRegion collapsed fold region to process */ private void advance(FoldRegion foldRegion, int placeHolderWidthInPixels) { lastFoldStartPosition = currentPosition.clone(); lastFold = foldRegion; int logicalLineBefore = currentPosition.logicalLine; currentPosition.advance(foldRegion); currentPosition.x += placeHolderWidthInPixels; if (currentPosition.logicalLine > logicalLineBefore) { final DocumentEx document = myEditor.getDocument(); int endFoldLine = document.getLineNumber(foldRegion.getEndOffset()); logicalLineData.endLineOffset = document.getLineEndOffset(endFoldLine); } tokenStartOffset = myContext.currentPosition.offset; softWrapStartOffset = foldRegion.getEndOffset(); lastFoldEndPosition = currentPosition.clone(); } /** * Asks current context to update its state in order to show to the first symbol of the next visual line if it belongs to * [{@link #tokenStartOffset}; {@link #skipToLineEnd} is set to <code>'true'</code> otherwise */ public boolean tryToShiftToNextLine() { while (currentPosition.offset < tokenEndOffset) { int c = Character.codePointAt(text, currentPosition.offset); if (c == '\n') { onNewLine(); // Assuming that offset is incremented during this method call return checkIsDoneAfterNewLine(); } else { onNonLineFeedSymbol(c, offsetToX(currentPosition.offset, c)); } } skipToLineEnd = true; return false; } /** * Allows to answer if point with the given <code>'x'</code> coordinate exceeds visual area's right edge. * * @param x target <code>'x'</code> coordinate to check * @return <code>true</code> if given <code>'x'</code> coordinate exceeds visual area's right edge; <code>false</code> otherwise */ public boolean exceedsVisualEdge(int x) { return x > myVisibleAreaWidth; } } /** * Primitive data structure to hold {@code int -> int} mappings assuming that the following is true: * <pre> * <ul> * <li>number of entries is small;</li> * <li>the keys are roughly adjacent;</li> * </ul> * </pre> */ private static class PrimitiveIntMap { private int[] myData = new int[16]; private int myShift; public int get(int key) { int index = key + myShift; if (index < 0 || index >= myData.length) { return -1; } return myData[index]; } public void put(int key, int value) { int index = key + myShift; if (index < 0) { int[] tmp = new int[myData.length - index]; System.arraycopy(myData, 0, tmp, -index, myData.length); myData = tmp; myShift -= index; index = 0; } myData[index] = value; } public void reset() { myShift = 0; Arrays.fill(myData, 0); } } }