cleanup (inspection "Java | Class structure | Utility class is not 'final'")
[idea/community.git] / platform / diff-impl / src / com / intellij / diff / tools / util / SyncScrollSupport.java
1 // Copyright 2000-2020 JetBrains s.r.o. Use of this source code is governed by the Apache 2.0 license that can be found in the LICENSE file.
2 package com.intellij.diff.tools.util;
3
4 import com.intellij.diff.util.Range;
5 import com.intellij.diff.util.Side;
6 import com.intellij.diff.util.ThreeSide;
7 import com.intellij.openapi.editor.Editor;
8 import com.intellij.openapi.editor.LogicalPosition;
9 import com.intellij.openapi.editor.ScrollingModel;
10 import com.intellij.openapi.editor.VisualPosition;
11 import com.intellij.openapi.editor.event.VisibleAreaEvent;
12 import com.intellij.openapi.editor.event.VisibleAreaListener;
13 import com.intellij.openapi.editor.ex.EditorEx;
14 import com.intellij.openapi.editor.impl.FoldingModelImpl;
15 import com.intellij.util.ArrayUtil;
16 import org.jetbrains.annotations.CalledInAwt;
17 import org.jetbrains.annotations.NotNull;
18 import org.jetbrains.annotations.Nullable;
19
20 import javax.swing.*;
21 import java.awt.*;
22 import java.util.Arrays;
23 import java.util.List;
24
25 public final class SyncScrollSupport {
26   public interface SyncScrollable {
27     @CalledInAwt
28     boolean isSyncScrollEnabled();
29
30     @CalledInAwt
31     int transfer(@NotNull Side baseSide, int line);
32
33     @NotNull
34     @CalledInAwt
35     Range getRange(@NotNull Side baseSide, int line);
36   }
37
38   public interface Support {
39     void enterDisableScrollSection();
40
41     void exitDisableScrollSection();
42   }
43
44   public static class TwosideSyncScrollSupport extends SyncScrollSupportBase {
45     @NotNull private final List<? extends Editor> myEditors;
46     @NotNull private final SyncScrollable myScrollable;
47
48     @NotNull private final ScrollHelper myHelper1;
49     @NotNull private final ScrollHelper myHelper2;
50
51     public TwosideSyncScrollSupport(@NotNull List<? extends Editor> editors, @NotNull SyncScrollable scrollable) {
52       myEditors = editors;
53       myScrollable = scrollable;
54
55       myHelper1 = create(Side.LEFT);
56       myHelper2 = create(Side.RIGHT);
57     }
58
59     @Override
60     @NotNull
61     protected List<? extends Editor> getEditors() {
62       return myEditors;
63     }
64
65     @Override
66     @NotNull
67     protected List<? extends ScrollHelper> getScrollHelpers() {
68       return Arrays.asList(myHelper1, myHelper2);
69     }
70
71     @NotNull
72     public SyncScrollable getScrollable() {
73       return myScrollable;
74     }
75
76     public void visibleAreaChanged(VisibleAreaEvent e) {
77       if (!myScrollable.isSyncScrollEnabled() || isDuringSyncScroll()) return;
78
79       enterDisableScrollSection();
80       try {
81         if (e.getEditor() == Side.LEFT.select(myEditors)) {
82           myHelper1.visibleAreaChanged(e);
83         }
84         else if (e.getEditor() == Side.RIGHT.select(myEditors)) {
85           myHelper2.visibleAreaChanged(e);
86         }
87       }
88       finally {
89         exitDisableScrollSection();
90       }
91     }
92
93     public void makeVisible(@NotNull Side masterSide,
94                             int startLine1, int endLine1, int startLine2, int endLine2,
95                             final boolean animate) {
96       doMakeVisible(masterSide.getIndex(), new int[]{startLine1, startLine2}, new int[]{endLine1, endLine2}, animate);
97     }
98
99     @NotNull
100     private ScrollHelper create(@NotNull Side side) {
101       return new ScrollHelper(myEditors, side.getIndex(), side.other().getIndex(), myScrollable, side);
102     }
103   }
104
105   public static class ThreesideSyncScrollSupport extends SyncScrollSupportBase {
106     @NotNull private final List<? extends Editor> myEditors;
107     @NotNull private final SyncScrollable myScrollable12;
108     @NotNull private final SyncScrollable myScrollable23;
109
110     @NotNull private final ScrollHelper myHelper12;
111     @NotNull private final ScrollHelper myHelper21;
112     @NotNull private final ScrollHelper myHelper23;
113     @NotNull private final ScrollHelper myHelper32;
114
115     public ThreesideSyncScrollSupport(@NotNull List<? extends Editor> editors,
116                                       @NotNull SyncScrollable scrollable12,
117                                       @NotNull SyncScrollable scrollable23) {
118       assert editors.size() == 3;
119
120       myEditors = editors;
121       myScrollable12 = scrollable12;
122       myScrollable23 = scrollable23;
123
124       myHelper12 = create(ThreeSide.LEFT, ThreeSide.BASE);
125       myHelper21 = create(ThreeSide.BASE, ThreeSide.LEFT);
126
127       myHelper23 = create(ThreeSide.BASE, ThreeSide.RIGHT);
128       myHelper32 = create(ThreeSide.RIGHT, ThreeSide.BASE);
129     }
130
131     @Override
132     @NotNull
133     protected List<? extends Editor> getEditors() {
134       return myEditors;
135     }
136
137     @Override
138     @NotNull
139     protected List<? extends ScrollHelper> getScrollHelpers() {
140       return Arrays.asList(myHelper12, myHelper21, myHelper23, myHelper32);
141     }
142
143     @NotNull
144     public SyncScrollable getScrollable12() {
145       return myScrollable12;
146     }
147
148     @NotNull
149     public SyncScrollable getScrollable23() {
150       return myScrollable23;
151     }
152
153     public void visibleAreaChanged(VisibleAreaEvent e) {
154       if (isDuringSyncScroll()) return;
155
156       enterDisableScrollSection();
157       try {
158         if (e.getEditor() == ThreeSide.LEFT.select(myEditors)) {
159           if (myScrollable12.isSyncScrollEnabled()) {
160             myHelper12.visibleAreaChanged(e);
161             if (myScrollable23.isSyncScrollEnabled()) myHelper23.visibleAreaChanged(e);
162           }
163         }
164         else if (e.getEditor() == ThreeSide.BASE.select(myEditors)) {
165           if (myScrollable12.isSyncScrollEnabled()) myHelper21.visibleAreaChanged(e);
166           if (myScrollable23.isSyncScrollEnabled()) myHelper23.visibleAreaChanged(e);
167         }
168         else if (e.getEditor() == ThreeSide.RIGHT.select(myEditors)) {
169           if (myScrollable23.isSyncScrollEnabled()) {
170             myHelper32.visibleAreaChanged(e);
171             if (myScrollable12.isSyncScrollEnabled()) myHelper21.visibleAreaChanged(e);
172           }
173         }
174       }
175       finally {
176         exitDisableScrollSection();
177       }
178     }
179
180     public void makeVisible(@NotNull ThreeSide masterSide, int[] startLines, int[] endLines, boolean animate) {
181       doMakeVisible(masterSide.getIndex(), startLines, endLines, animate);
182     }
183
184     @NotNull
185     private ScrollHelper create(@NotNull ThreeSide master, @NotNull ThreeSide slave) {
186       assert master != slave;
187       assert master == ThreeSide.BASE || slave == ThreeSide.BASE;
188
189       boolean leftSide = master == ThreeSide.LEFT || slave == ThreeSide.LEFT;
190       SyncScrollable scrollable = leftSide ? myScrollable12 : myScrollable23;
191
192       Side side;
193       if (leftSide) {
194         // LEFT - BASE -> LEFT
195         // BASE - LEFT -> RIGHT
196         side = Side.fromLeft(master == ThreeSide.LEFT);
197       }
198       else {
199         // BASE - RIGHT -> LEFT
200         // RIGHT - BASE -> RIGHT
201         side = Side.fromLeft(master == ThreeSide.BASE);
202       }
203
204       return new ScrollHelper(myEditors, master.getIndex(), slave.getIndex(), scrollable, side);
205     }
206   }
207
208   //
209   // Impl
210   //
211
212   private abstract static class SyncScrollSupportBase implements Support {
213     private int myDuringSyncScrollDepth = 0;
214
215     public boolean isDuringSyncScroll() {
216       return myDuringSyncScrollDepth > 0;
217     }
218
219     @Override
220     public void enterDisableScrollSection() {
221       myDuringSyncScrollDepth++;
222     }
223
224     @Override
225     public void exitDisableScrollSection() {
226       myDuringSyncScrollDepth--;
227       assert myDuringSyncScrollDepth >= 0;
228     }
229
230     @NotNull
231     protected abstract List<? extends Editor> getEditors();
232
233     @NotNull
234     protected abstract List<? extends ScrollHelper> getScrollHelpers();
235
236     protected void doMakeVisible(final int masterIndex, int[] startLines, int[] endLines, final boolean animate) {
237       final List<? extends Editor> editors = getEditors();
238       final List<? extends ScrollHelper> helpers = getScrollHelpers();
239
240       final int count = editors.size();
241       assert startLines.length == count;
242       assert endLines.length == count;
243
244       final int[] offsets = getTargetOffsets(editors.toArray(Editor.EMPTY_ARRAY), startLines, endLines, -1);
245
246       final int[] startOffsets = new int[count];
247       for (int i = 0; i < count; i++) {
248         startOffsets[i] = editors.get(i).getScrollingModel().getVisibleArea().y;
249       }
250
251       final Editor masterEditor = editors.get(masterIndex);
252       final int masterOffset = offsets[masterIndex];
253       final int masterStartOffset = startOffsets[masterIndex];
254
255       for (ScrollHelper helper : helpers) {
256         helper.setAnchor(startOffsets[helper.getMasterIndex()], offsets[helper.getMasterIndex()],
257                          startOffsets[helper.getSlaveIndex()], offsets[helper.getSlaveIndex()]);
258       }
259
260       doScrollHorizontally(masterEditor, 0, false); // animation will be canceled by "scroll vertically" anyway
261       doScrollVertically(masterEditor, masterOffset, animate);
262
263       masterEditor.getScrollingModel().runActionOnScrollingFinished(() -> {
264         for (ScrollHelper helper : helpers) {
265           helper.removeAnchor();
266         }
267
268         int masterFinalOffset = masterEditor.getScrollingModel().getVisibleArea().y;
269         boolean animateSlaves = animate && masterFinalOffset == masterStartOffset;
270         for (int i = 0; i < count; i++) {
271           if (i == masterIndex) continue;
272           Editor editor = editors.get(i);
273
274           int finalOffset = editor.getScrollingModel().getVisibleArea().y;
275           if (finalOffset != offsets[i]) {
276             enterDisableScrollSection();
277
278             doScrollVertically(editor, offsets[i], animateSlaves);
279
280             editor.getScrollingModel().runActionOnScrollingFinished(this::exitDisableScrollSection);
281           }
282         }
283       });
284     }
285   }
286
287   private static class ScrollHelper implements VisibleAreaListener {
288     @NotNull private final List<? extends Editor> myEditors;
289     private final int myMasterIndex;
290     private final int mySlaveIndex;
291     @NotNull private final SyncScrollable myScrollable;
292     @NotNull private final Side mySide;
293
294     @Nullable private Anchor myAnchor;
295
296     ScrollHelper(@NotNull List<? extends Editor> editors,
297                         int masterIndex,
298                         int slaveIndex,
299                         @NotNull SyncScrollable scrollable,
300                         @NotNull Side side) {
301       myEditors = editors;
302       myMasterIndex = masterIndex;
303       mySlaveIndex = slaveIndex;
304       myScrollable = scrollable;
305       mySide = side;
306     }
307
308     public void setAnchor(int masterStartOffset, int masterEndOffset, int slaveStartOffset, int slaveEndOffset) {
309       myAnchor = new Anchor(masterStartOffset, masterEndOffset, slaveStartOffset, slaveEndOffset);
310     }
311
312     public void removeAnchor() {
313       myAnchor = null;
314     }
315
316     @Override
317     public void visibleAreaChanged(@NotNull VisibleAreaEvent e) {
318       if (((FoldingModelImpl)getSlave().getFoldingModel()).isInBatchFoldingOperation()) return;
319       if (getMaster().isDisposed() || getSlave().isDisposed()) return;
320
321       Rectangle newRectangle = e.getNewRectangle();
322       Rectangle oldRectangle = e.getOldRectangle();
323       if (oldRectangle == null) return;
324
325       if (newRectangle.x != oldRectangle.x) syncHorizontalScroll(false);
326       if (newRectangle.y != oldRectangle.y) syncVerticalScroll(false);
327     }
328
329     public int getMasterIndex() {
330       return myMasterIndex;
331     }
332
333     public int getSlaveIndex() {
334       return mySlaveIndex;
335     }
336
337     @NotNull
338     public Editor getMaster() {
339       return myEditors.get(myMasterIndex);
340     }
341
342     @NotNull
343     public Editor getSlave() {
344       return myEditors.get(mySlaveIndex);
345     }
346
347     private void syncVerticalScroll(boolean animated) {
348       Editor master = getMaster();
349       Editor slave = getSlave();
350
351       if (master.getDocument().getTextLength() == 0) return;
352
353       Rectangle viewRect = master.getScrollingModel().getVisibleArea();
354       int lineHeight = master.getLineHeight();
355
356       boolean onlyMajorForward = false;
357       boolean onlyMajorBackward = false;
358       int offset;
359       if (myAnchor == null) {
360         int middleY = viewRect.height / 3;
361         int masterOffset = viewRect.y + middleY;
362
363         int masterVisualLine = master.yToVisualLine(masterOffset);
364         int convertedVisualLine = transferVisualLine(masterVisualLine);
365
366         int slaveOffset = slave.visualLineToY(convertedVisualLine);
367         int masterOffsetRaw = master.visualLineToY(masterVisualLine);
368         // ensure that anchor lines are in the same phase
369         int correction = (masterOffset - masterOffsetRaw) % lineHeight;
370
371         offset = slaveOffset - middleY + correction;
372
373         onlyMajorBackward = correction < lineHeight / 2 && masterVisualLine > 0 &&
374                             convertedVisualLine == transferVisualLine(masterVisualLine - 1);
375         onlyMajorForward = correction > lineHeight / 2 &&
376                            convertedVisualLine == transferVisualLine(masterVisualLine + 1);
377       }
378       else {
379         double progress = myAnchor.masterStartOffset == myAnchor.masterEndOffset || viewRect.y == myAnchor.masterEndOffset ? 1 :
380                           ((double)(viewRect.y - myAnchor.masterStartOffset)) / (myAnchor.masterEndOffset - myAnchor.masterStartOffset);
381
382         offset = myAnchor.slaveStartOffset + (int)((myAnchor.slaveEndOffset - myAnchor.slaveStartOffset) * progress);
383       }
384
385       int deltaHeaderOffset = getHeaderOffset(slave) - getHeaderOffset(master);
386       doScrollVertically(slave, offset + deltaHeaderOffset, animated, onlyMajorForward, onlyMajorBackward);
387     }
388
389     private int transferVisualLine(int masterVisualLine) {
390       Editor master = getMaster();
391       Editor slave = getSlave();
392
393       int masterCenterLine = master.visualToLogicalPosition(new VisualPosition(masterVisualLine, 0)).line;
394       Range range = myScrollable.getRange(mySide, masterCenterLine);
395
396       int masterStart = logicalToVisualLine(master, range.start1);
397       int masterEnd = range.start1 == range.end1 ? masterStart : logicalToVisualLine(master, range.end1);
398
399       int slaveStart = logicalToVisualLine(slave, range.start2);
400       int slaveEnd = range.start2 == range.end2 ? slaveStart : logicalToVisualLine(slave, range.end2);
401
402       Range visualRange = new Range(masterStart, masterEnd, slaveStart, slaveEnd);
403       return BaseSyncScrollable.transferLine(masterVisualLine, visualRange);
404     }
405
406     private static int logicalToVisualLine(@NotNull Editor editor, int line) {
407       return editor.logicalToVisualPosition(new LogicalPosition(line, 0)).line;
408     }
409
410     private void syncHorizontalScroll(boolean animated) {
411       int offset = getMaster().getScrollingModel().getVisibleArea().x;
412       doScrollHorizontally(getSlave(), offset, animated);
413     }
414   }
415
416   private static void doScrollVertically(@NotNull Editor editor, int offset, boolean animated) {
417     doScrollVertically(editor, offset, animated, false, false);
418   }
419
420   private static void doScrollVertically(@NotNull Editor editor, int offset, boolean animated,
421                                          boolean onlyMajorForward, boolean onlyMajorBackward) {
422     ScrollingModel model = editor.getScrollingModel();
423
424     int currentOffset = model.getVerticalScrollOffset();
425     if (onlyMajorForward && offset > currentOffset ||
426         onlyMajorBackward && offset < currentOffset) {
427       if (Math.abs(offset - currentOffset) < editor.getLineHeight()) {
428         return;
429       }
430     }
431
432     if (!animated) model.disableAnimation();
433     model.scrollVertically(offset);
434     if (!animated) model.enableAnimation();
435   }
436
437   private static void doScrollHorizontally(@NotNull Editor editor, int offset, boolean animated) {
438     ScrollingModel model = editor.getScrollingModel();
439     if (!animated) model.disableAnimation();
440     model.scrollHorizontally(offset);
441     if (!animated) model.enableAnimation();
442   }
443
444   private static int getHeaderOffset(@NotNull final Editor editor) {
445     final JComponent header = editor.getHeaderComponent();
446     return header == null ? 0 : header.getHeight();
447   }
448
449   public static int @NotNull [] getTargetOffsets(@NotNull Editor editor1, @NotNull Editor editor2,
450                                                  int startLine1, int endLine1, int startLine2, int endLine2,
451                                                  int preferredTopShift) {
452     return getTargetOffsets(new Editor[]{editor1, editor2},
453                             new int[]{startLine1, startLine2},
454                             new int[]{endLine1, endLine2},
455                             preferredTopShift);
456   }
457
458   private static int @NotNull [] getTargetOffsets(Editor @NotNull [] editors, int[] startLines, int[] endLines, int preferredTopShift) {
459     int count = editors.length;
460     assert startLines.length == count;
461     assert endLines.length == count;
462
463     int[] topOffsets = new int[count];
464     int[] bottomOffsets = new int[count];
465     int[] rangeHeights = new int[count];
466     int[] gapLines = new int[count];
467     int[] editorHeights = new int[count];
468     int[] maximumOffsets = new int[count];
469     int[] topShifts = new int[count];
470
471     for (int i = 0; i < count; i++) {
472       topOffsets[i] = editors[i].logicalPositionToXY(new LogicalPosition(startLines[i], 0)).y;
473       bottomOffsets[i] = editors[i].logicalPositionToXY(new LogicalPosition(endLines[i] + 1, 0)).y;
474       rangeHeights[i] = bottomOffsets[i] - topOffsets[i];
475
476       gapLines[i] = 2 * editors[i].getLineHeight();
477       editorHeights[i] = editors[i].getScrollingModel().getVisibleArea().height;
478
479       maximumOffsets[i] = ((EditorEx)editors[i]).getScrollPane().getVerticalScrollBar().getMaximum() - editorHeights[i];
480
481       // 'shift' here - distance between editor's top and first line of range
482
483       // make whole range visible. If possible, locate it at 'center' (1/3 of height) (or at 'preferredTopShift' if it was specified)
484       // If can't show whole range - show as much as we can
485       boolean canShow = 2 * gapLines[i] + rangeHeights[i] <= editorHeights[i];
486
487       int shift = preferredTopShift != -1 ? preferredTopShift : editorHeights[i] / 3;
488       topShifts[i] = canShow ? Math.min(editorHeights[i] - gapLines[i] - rangeHeights[i], shift) : gapLines[i];
489     }
490
491     int topShift = ArrayUtil.min(topShifts);
492
493     // check if we're at the top of file
494     topShift = Math.min(topShift, ArrayUtil.min(topOffsets));
495
496     int[] offsets = new int[count];
497     boolean haveEnoughSpace = true;
498     for (int i = 0; i < count; i++) {
499       offsets[i] = topOffsets[i] - topShift;
500       haveEnoughSpace &= maximumOffsets[i] > offsets[i];
501     }
502
503     if (haveEnoughSpace) return offsets;
504
505     // One of the ranges is at end of file - we can't scroll where we want to.
506     topShift = 0;
507     for (int i = 0; i < count; i++) {
508       topShift = Math.max(topOffsets[i] - maximumOffsets[i], topShift);
509     }
510
511     for (int i = 0; i < count; i++) {
512       // Try to show as much of range as we can (even if it breaks alignment)
513       offsets[i] = topOffsets[i] - topShift + Math.max(topShift + rangeHeights[i] + gapLines[i] - editorHeights[i], 0);
514
515       // always show top of the range
516       offsets[i] = Math.min(offsets[i], topOffsets[i] - gapLines[i]);
517     }
518
519     return offsets;
520   }
521
522   private static class Anchor {
523     public final int masterStartOffset;
524     public final int masterEndOffset;
525     public final int slaveStartOffset;
526     public final int slaveEndOffset;
527
528     Anchor(int masterStartOffset, int masterEndOffset, int slaveStartOffset, int slaveEndOffset) {
529       this.masterStartOffset = masterStartOffset;
530       this.masterEndOffset = masterEndOffset;
531       this.slaveStartOffset = slaveStartOffset;
532       this.slaveEndOffset = slaveEndOffset;
533     }
534   }
535 }