/*
* Copyright 2000-2015 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.diff.tools.util;
import com.intellij.diff.util.DiffDividerDrawUtil;
import com.intellij.diff.util.DiffDrawUtil;
import com.intellij.diff.util.DiffUtil;
import com.intellij.diff.util.LineRange;
import com.intellij.openapi.Disposable;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.editor.Editor;
import com.intellij.openapi.editor.FoldRegion;
import com.intellij.openapi.editor.event.DocumentEvent;
import com.intellij.openapi.editor.ex.DocumentEx;
import com.intellij.openapi.editor.ex.EditorEx;
import com.intellij.openapi.editor.ex.FoldingListener;
import com.intellij.openapi.editor.ex.FoldingModelEx;
import com.intellij.openapi.editor.markup.RangeHighlighter;
import com.intellij.openapi.util.BooleanGetter;
import com.intellij.openapi.util.Computable;
import com.intellij.openapi.util.Key;
import com.intellij.openapi.util.UserDataHolder;
import com.intellij.openapi.util.text.StringUtil;
import com.intellij.util.Function;
import com.intellij.util.containers.ContainerUtil;
import com.intellij.util.containers.HashSet;
import gnu.trove.TIntFunction;
import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.awt.*;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
/*
* This class allows to add custom foldings to hide unchanged regions in diff.
* EditorSettings#isAutoCodeFoldingEnabled() should be true, to avoid collisions with language-specific foldings
* (as it's impossible to create partially overlapped folding regions)
* @See DiffUtil.setFoldingModelSupport()
*
*/
public class FoldingModelSupport {
public static final String PLACEHOLDER = " ";
private static final Key<FoldingCache> CACHE_KEY = Key.create("Diff.FoldingUtil.Cache");
protected final int myCount;
@NotNull protected final EditorEx[] myEditors;
@NotNull protected final List<FoldedBlock[]> myFoldings = new ArrayList<FoldedBlock[]>();
private boolean myDuringSynchronize;
private final boolean[] myShouldUpdateLineNumbers;
public FoldingModelSupport(@NotNull EditorEx[] editors, @NotNull Disposable disposable) {
myEditors = editors;
myCount = myEditors.length;
myShouldUpdateLineNumbers = new boolean[myCount];
if (myCount > 1) {
for (int i = 0; i < myCount; i++) {
myEditors[i].getFoldingModel().addListener(new MyFoldingListener(i), disposable);
myEditors[i].getGutterComponentEx().setLineNumberConvertor(getLineConvertor(i));
}
}
}
//
// Init
//
/*
* Iterator returns ranges of changed lines: start1, end1, start2, end2, ...
*/
protected void install(@Nullable final Iterator<int[]> changedLines,
@NotNull final UserDataHolder context,
@NotNull final Settings settings) {
ApplicationManager.getApplication().assertIsDispatchThread();
if (changedLines == null) return;
if (settings.range == -1) return;
runBatchOperation(new Runnable() {
@Override
public void run() {
FoldingBuilder builder = new FoldingBuilder(context, settings);
builder.build(changedLines);
}
});
updateLineNumbers(true);
}
private class FoldingBuilder {
@NotNull private final Settings mySettings;
@NotNull private final ExpandSuggester myExpandSuggester;
@NotNull private final int[] myLineCount;
public FoldingBuilder(@NotNull UserDataHolder context,
@NotNull Settings settings) {
myExpandSuggester = new ExpandSuggester(context.getUserData(CACHE_KEY), settings.defaultExpanded);
mySettings = settings;
myLineCount = new int[myCount];
for (int i = 0; i < myCount; i++) {
myLineCount[i] = myEditors[i].getDocument().getLineCount();
}
}
private void build(@NotNull final Iterator<int[]> changedLines) {
int[] starts = new int[myCount];
int[] ends = new int[myCount];
int[] last = new int[myCount];
for (int i = 0; i < myCount; i++) {
last[i] = Integer.MIN_VALUE;
}
while (changedLines.hasNext()) {
int[] offsets = changedLines.next();
for (int i = 0; i < myCount; i++) {
starts[i] = last[i];
ends[i] = offsets[i * 2];
last[i] = offsets[i * 2 + 1];
}
addRange(starts, ends);
}
for (int i = 0; i < myCount; i++) {
starts[i] = last[i];
ends[i] = Integer.MAX_VALUE;
}
addRange(starts, ends);
}
private void addRange(int[] starts, int[] ends) {
List<FoldedBlock> result = new ArrayList<FoldedBlock>(3);
int[] rangeStarts = new int[myCount];
int[] rangeEnds = new int[myCount];
for (int number = 0; ; number++) {
int shift = getRangeShift(mySettings.range, number);
if (shift == -1) break;
for (int i = 0; i < myCount; i++) {
rangeStarts[i] = bound(starts[i] + shift, i);
rangeEnds[i] = bound(ends[i] - shift, i);
}
ContainerUtil.addAllNotNull(result, createRange(rangeStarts, rangeEnds, myExpandSuggester.isExpanded(rangeStarts, rangeEnds)));
}
if (result.size() > 0) {
FoldedBlock[] block = ContainerUtil.toArray(result, new FoldedBlock[result.size()]);
for (FoldedBlock folding : block) {
folding.installHighlighter(block);
}
myFoldings.add(block);
}
}
@Nullable
private FoldedBlock createRange(int[] starts, int[] ends, boolean expanded) {
boolean hasFolding = false;
FoldRegion[] regions = new FoldRegion[myCount];
boolean hasExpanded = false; // do not desync on runBatchFoldingOperationDoNotCollapseCaret
for (int i = 0; i < myCount; i++) {
if (ends[i] - starts[i] < 2) continue;
regions[i] = addFolding(myEditors[i], starts[i], ends[i], expanded);
hasFolding |= regions[i] != null;
hasExpanded |= regions[i] != null && regions[i].isExpanded();
}
if (hasExpanded && !expanded) {
for (FoldRegion region : regions) {
if (region != null) region.setExpanded(true);
}
}
return hasFolding ? new FoldedBlock(regions) : null;
}
private int bound(int value, int index) {
return Math.min(Math.max(value, 0), myLineCount[index]);
}
}
@Nullable
private static FoldRegion addFolding(@NotNull EditorEx editor, int start, int end, boolean expanded) {
DocumentEx document = editor.getDocument();
final int startOffset = document.getLineStartOffset(start);
final int endOffset = document.getLineEndOffset(end - 1);
FoldRegion value = editor.getFoldingModel().addFoldRegion(startOffset, endOffset, PLACEHOLDER);
if (value != null) value.setExpanded(expanded);
return value;
}
private void runBatchOperation(@NotNull Runnable runnable) {
Runnable lastRunnable = runnable;
for (int i = 0; i < myCount; i++) {
final Editor editor = myEditors[i];
final Runnable finalRunnable = lastRunnable;
lastRunnable = new Runnable() {
@Override
public void run() {
Runnable operation = new Runnable() {
@Override
public void run() {
finalRunnable.run();
}
};
if (DiffUtil.isFocusedComponent(editor.getComponent())) {
editor.getFoldingModel().runBatchFoldingOperationDoNotCollapseCaret(operation);
}
else {
editor.getFoldingModel().runBatchFoldingOperation(operation);
}
}
};
}
myDuringSynchronize = true;
try {
lastRunnable.run();
}
finally {
myDuringSynchronize = false;
}
}
public void destroy() {
for (int i = 0; i < myCount; i++) {
destroyFoldings(i);
}
for (FoldedBlock folding : getFoldedBlocks()) {
folding.destroyHighlighter();
}
myFoldings.clear();
}
private void destroyFoldings(final int index) {
final FoldingModelEx model = myEditors[index].getFoldingModel();
model.runBatchFoldingOperation(new Runnable() {
@Override
public void run() {
for (FoldedBlock folding : getFoldedBlocks()) {
FoldRegion region = folding.getRegion(index);
if (region != null) model.removeFoldRegion(region);
}
}
});
}
//
// Line numbers
//
public void onDocumentChanged(@NotNull DocumentEvent e) {
if (StringUtil.indexOf(e.getOldFragment(), '\n') != -1 ||
StringUtil.indexOf(e.getNewFragment(), '\n') != -1) {
for (int i = 0; i < myCount; i++) {
if (myEditors[i].getDocument() == e.getDocument()) {
myShouldUpdateLineNumbers[i] = true;
}
}
}
}
@NotNull
protected TIntFunction getLineConvertor(final int index) {
return new TIntFunction() {
@Override
public int execute(int value) {
updateLineNumbers(false);
for (FoldedBlock folding : getFoldedBlocks()) { // TODO: avoid full scan - it could slowdown painting
int line = folding.getLine(index);
if (line == -1) continue;
if (line > value) break;
FoldRegion region = folding.getRegion(index);
if (line == value && region != null && !region.isExpanded()) return -1;
}
return value;
}
};
}
private void updateLineNumbers(boolean force) {
for (int i = 0; i < myCount; i++) {
if (!myShouldUpdateLineNumbers[i] && !force) continue;
myShouldUpdateLineNumbers[i] = false;
ApplicationManager.getApplication().assertReadAccessAllowed();
for (FoldedBlock folding : getFoldedBlocks()) {
folding.updateLineNumber(i);
}
}
}
//
// Synchronized toggling of ranges
//
public void expandAll(final boolean expanded) {
if (myDuringSynchronize) return;
myDuringSynchronize = true;
try {
for (int i = 0; i < myCount; i++) {
final int index = i;
final FoldingModelEx model = myEditors[index].getFoldingModel();
model.runBatchFoldingOperation(new Runnable() {
@Override
public void run() {
for (FoldedBlock folding : getFoldedBlocks()) {
FoldRegion region = folding.getRegion(index);
if (region != null) region.setExpanded(expanded);
}
}
});
}
}
finally {
myDuringSynchronize = false;
}
}
private class MyFoldingListener implements FoldingListener {
private final int myIndex;
@NotNull Set<FoldRegion> myModifiedRegions = new HashSet<FoldRegion>();
public MyFoldingListener(int index) {
myIndex = index;
}
@Override
public void onFoldRegionStateChange(@NotNull FoldRegion region) {
if (myDuringSynchronize) return;
myModifiedRegions.add(region);
}
@Override
public void onFoldProcessingEnd() {
if (myModifiedRegions.isEmpty()) return;
myDuringSynchronize = true;
try {
for (int i = 0; i < myCount; i++) {
if (i == myIndex) continue;
final int pairedIndex = i;
myEditors[pairedIndex].getFoldingModel().runBatchFoldingOperation(new Runnable() {
@Override
public void run() {
for (FoldedBlock folding : getFoldedBlocks()) {
FoldRegion region = folding.getRegion(myIndex);
if (region == null || !region.isValid()) continue;
if (myModifiedRegions.contains(region)) {
FoldRegion pairedRegion = folding.getRegion(pairedIndex);
if (pairedRegion == null || !pairedRegion.isValid()) continue;
pairedRegion.setExpanded(region.isExpanded());
}
}
}
});
}
myModifiedRegions.clear();
}
finally {
myDuringSynchronize = false;
}
}
}
//
// Highlighting
//
protected class MyPaintable implements DiffDividerDrawUtil.DividerSeparatorPaintable {
private final int myLeft;
private final int myRight;
public MyPaintable(int left, int right) {
myLeft = left;
myRight = right;
}
@Override
public void process(@NotNull Handler handler) {
for (FoldedBlock[] block : myFoldings) {
for (FoldedBlock folding : block) {
FoldRegion region1 = folding.getRegion(myLeft);
FoldRegion region2 = folding.getRegion(myRight);
if (region1 == null || !region1.isValid() || region1.isExpanded()) continue;
if (region2 == null || !region2.isValid() || region2.isExpanded()) continue;
int line1 = myEditors[myLeft].getDocument().getLineNumber(region1.getStartOffset());
int line2 = myEditors[myRight].getDocument().getLineNumber(region2.getStartOffset());
if (!handler.process(line1, line2)) return;
break;
}
}
}
public void paintOnDivider(@NotNull Graphics2D gg, @NotNull Component divider) {
DiffDividerDrawUtil.paintSeparators(gg, divider.getWidth(), myEditors[myLeft], myEditors[myRight], this);
}
public void paintOnScrollbar(@NotNull Graphics2D gg, int width) {
DiffDividerDrawUtil.paintSeparatorsOnScrollbar(gg, width, myEditors[myLeft], myEditors[myRight], this);
}
}
//
// Cache
//
/*
* To Cache:
* For each block of foldings (foldings for a single unchanged block in diff) we remember biggest expanded and biggest collapsed range.
*
* From Cache:
* We look into cache while building ranges, trying to find corresponding range in cached state.
* "Corresponding range" now is just smallest covering range.
*
* If document was modified since cache creation, this will lead to strange results. But this is a rare case, and we can't do anything with it.
*/
private class ExpandSuggester {
@Nullable private final FoldingCache myCache;
private final int[] myIndex = new int[myCount];
private final boolean myDefault;
public ExpandSuggester(@Nullable FoldingCache cache, boolean defaultValue) {
myCache = cache;
myDefault = defaultValue;
}
public boolean isExpanded(int[] starts, int[] ends) {
if (myCache == null || myCache.ranges.length != myCount) return myDefault;
if (myDefault != myCache.expandByDefault) return myDefault;
Boolean state = null;
for (int index = 0; index < myCount; index++) {
Boolean sideState = getCachedExpanded(starts[index], ends[index], index);
if (sideState == null) continue;
if (state == null) {
state = sideState;
continue;
}
if (state != sideState) return myDefault;
}
return state == null ? myDefault : state;
}
@Nullable
private Boolean getCachedExpanded(int start, int end, int index) {
if (start == end) return null;
//noinspection ConstantConditions
List<FoldedRangeState> ranges = myCache.ranges[index];
for (; myIndex[index] < ranges.size(); myIndex[index]++) {
FoldedRangeState range = ranges.get(myIndex[index]);
LineRange lineRange = range.getLineRange();
if (lineRange.end <= start) continue;
if (lineRange.contains(start, end)) {
if (range.collapsed != null && range.collapsed.contains(start, end)) return false;
if (range.expanded != null && range.expanded.contains(start, end)) return true;
assert false : "Invalid LineRange" + range.expanded + ", " + range.collapsed + ", " + new LineRange(start, end);
}
if (lineRange.start >= start) return null; // we could need current range for enclosing next-level foldings
}
return null;
}
}
public void updateContext(@NotNull UserDataHolder context, @NotNull final Settings settings) {
if (myFoldings.isEmpty()) return; // do not rewrite cache by initial state
context.putUserData(CACHE_KEY, getFoldingCache(settings));
}
@NotNull
private FoldingCache getFoldingCache(@NotNull final Settings settings) {
return ApplicationManager.getApplication().runReadAction(new Computable<FoldingCache>() {
@Override
public FoldingCache compute() {
List<FoldedRangeState>[] result = new List[myCount];
for (int i = 0; i < myCount; i++) {
result[i] = getFoldedRanges(i, settings);
}
return new FoldingCache(result, settings.defaultExpanded);
}
});
}
@NotNull
private List<FoldedRangeState> getFoldedRanges(int index, @NotNull Settings settings) {
ApplicationManager.getApplication().assertReadAccessAllowed();
List<FoldedRangeState> ranges = new ArrayList<FoldedRangeState>();
DocumentEx document = myEditors[index].getDocument();
for (FoldedBlock[] blocks : myFoldings) {
LineRange expanded = null;
LineRange collapsed = null;
for (FoldedBlock folding : blocks) {
FoldRegion region = folding.getRegion(index);
if (region == null || !region.isValid()) continue;
if (region.isExpanded()) {
if (expanded == null) {
int line1 = document.getLineNumber(region.getStartOffset());
int line2 = document.getLineNumber(region.getEndOffset()) + 1;
expanded = new LineRange(line1, line2);
}
}
else {
int line1 = document.getLineNumber(region.getStartOffset());
int line2 = document.getLineNumber(region.getEndOffset()) + 1;
collapsed = new LineRange(line1, line2);
break;
}
}
if (expanded != null || collapsed != null) {
ranges.add(new FoldedRangeState(expanded, collapsed));
}
}
return ranges;
}
private static class FoldingCache {
public final boolean expandByDefault;
@NotNull public final List<FoldedRangeState>[] ranges;
public FoldingCache(@NotNull List<FoldedRangeState>[] ranges, boolean expandByDefault) {
this.ranges = ranges;
this.expandByDefault = expandByDefault;
}
}
private static class FoldedRangeState {
@Nullable public final LineRange expanded;
@Nullable public final LineRange collapsed;
public FoldedRangeState(@Nullable LineRange expanded, @Nullable LineRange collapsed) {
assert expanded != null || collapsed != null;
this.expanded = expanded;
this.collapsed = collapsed;
}
@NotNull
public LineRange getLineRange() {
//noinspection ConstantConditions
return expanded != null ? expanded : collapsed;
}
}
//
// Impl
//
@NotNull
private Iterable<FoldedBlock> getFoldedBlocks() {
return new Iterable<FoldedBlock>() {
@NotNull
@Override
public Iterator<FoldedBlock> iterator() {
return new Iterator<FoldedBlock>() {
private int myGroupIndex = 0;
private int myBlockIndex = 0;
@Override
public boolean hasNext() {
return myGroupIndex < myFoldings.size();
}
@Override
public FoldedBlock next() {
FoldedBlock[] group = myFoldings.get(myGroupIndex);
FoldedBlock folding = group[myBlockIndex];
if (group.length > myBlockIndex + 1) {
myBlockIndex++;
}
else {
myGroupIndex++;
myBlockIndex = 0;
}
return folding;
}
@Override
public void remove() {
throw new UnsupportedOperationException();
}
};
}
};
}
protected class FoldedBlock {
@NotNull private final FoldRegion[] myRegions;
@NotNull private final int[] myLines;
@NotNull private final List<RangeHighlighter> myHighlighters = new ArrayList<RangeHighlighter>(myCount);
public FoldedBlock(@NotNull FoldRegion[] regions) {
assert regions.length == myCount;
myRegions = regions;
myLines = new int[myCount];
}
public void installHighlighter(@NotNull final FoldedBlock[] block) {
assert myHighlighters.isEmpty();
for (int i = 0; i < myCount; i++) {
FoldRegion region = myRegions[i];
if (region == null || !region.isValid()) continue;
myHighlighters.addAll(DiffDrawUtil.createLineSeparatorHighlighter(myEditors[i],
region.getStartOffset(), region.getEndOffset(),
getHighlighterCondition(block, i)));
}
}
public void destroyHighlighter() {
for (RangeHighlighter highlighter : myHighlighters) {
highlighter.dispose();
}
myHighlighters.clear();
}
public void updateLineNumber(int index) {
FoldRegion region = myRegions[index];
if (region == null || !region.isValid()) {
myLines[index] = -1;
}
else {
myLines[index] = myEditors[index].getDocument().getLineNumber(region.getStartOffset());
}
}
@Nullable
public FoldRegion getRegion(int index) {
return myRegions[index];
}
public int getLine(int index) {
return myLines[index];
}
@NotNull
private BooleanGetter getHighlighterCondition(@NotNull final FoldedBlock[] block, final int index) {
return new BooleanGetter() {
@Override
public boolean get() {
if (!myEditors[index].getFoldingModel().isFoldingEnabled()) return false;
for (FoldedBlock folding : block) {
FoldRegion region = folding.getRegion(index);
boolean visible = region != null && region.isValid() && !region.isExpanded();
if (folding == FoldedBlock.this) return visible;
if (visible) return false; // do not paint separator, if 'parent' folding is collapsed
}
return false;
}
};
}
}
//
// Helpers
//
/*
* number - depth of folding insertion (from zero)
* return: number of context lines. ('-1' - end)
*/
private static int getRangeShift(int range, int number) {
switch (number) {
case 0:
return range;
case 1:
return range * 2;
case 2:
return range * 4;
default:
return -1;
}
}
@Nullable
@Contract("null, _ -> null; !null, _ -> !null")
protected static <T, V> Iterator<V> map(@Nullable final List<T> list, @NotNull final Function<T, V> mapping) {
if (list == null) return null;
final Iterator<T> it = list.iterator();
return new Iterator<V>() {
@Override
public boolean hasNext() {
return it.hasNext();
}
@Override
public V next() {
return mapping.fun(it.next());
}
@Override
public void remove() {
}
};
}
public static class Settings {
public final int range;
public final boolean defaultExpanded;
public Settings(int range, boolean defaultExpanded) {
this.range = range;
this.defaultExpanded = defaultExpanded;
}
}
}