replaced <code></code> with more concise {@code}
[idea/community.git] / platform / platform-impl / src / com / intellij / openapi / editor / impl / ScrollingModelImpl.java
1 /*
2  * Copyright 2000-2017 JetBrains s.r.o.
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  * http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16
17 package com.intellij.openapi.editor.impl;
18
19 import com.intellij.ide.RemoteDesktopService;
20 import com.intellij.openapi.application.ex.ApplicationManagerEx;
21 import com.intellij.openapi.command.CommandProcessor;
22 import com.intellij.openapi.diagnostic.Logger;
23 import com.intellij.openapi.editor.LogicalPosition;
24 import com.intellij.openapi.editor.ScrollType;
25 import com.intellij.openapi.editor.VisualPosition;
26 import com.intellij.openapi.editor.event.DocumentEvent;
27 import com.intellij.openapi.editor.event.DocumentListener;
28 import com.intellij.openapi.editor.event.VisibleAreaEvent;
29 import com.intellij.openapi.editor.event.VisibleAreaListener;
30 import com.intellij.openapi.editor.ex.ScrollingModelEx;
31 import com.intellij.openapi.editor.ex.util.EditorUtil;
32 import com.intellij.openapi.fileEditor.impl.text.AsyncEditorLoader;
33 import com.intellij.openapi.util.Disposer;
34 import com.intellij.ui.components.Interpolable;
35 import com.intellij.util.SystemProperties;
36 import com.intellij.util.containers.ContainerUtil;
37 import com.intellij.util.ui.Animator;
38 import org.jetbrains.annotations.NotNull;
39 import org.jetbrains.annotations.Nullable;
40
41 import javax.swing.*;
42 import javax.swing.event.ChangeEvent;
43 import javax.swing.event.ChangeListener;
44 import java.awt.*;
45 import java.util.ArrayList;
46 import java.util.List;
47
48 public class ScrollingModelImpl implements ScrollingModelEx {
49   private static final Logger LOG = Logger.getInstance("#com.intellij.openapi.editor.impl.ScrollingModelImpl");
50
51   private final EditorImpl myEditor;
52   private final List<VisibleAreaListener> myVisibleAreaListeners = ContainerUtil.createLockFreeCopyOnWriteList();
53
54   private AnimatedScrollingRunnable myCurrentAnimationRequest = null;
55   private boolean myAnimationDisabled = false;
56
57   private int myAccumulatedXOffset = -1;
58   private int myAccumulatedYOffset = -1;
59   private boolean myAccumulateViewportChanges;
60   private boolean myViewportPositioned;
61
62   private final DocumentListener myDocumentListener = new DocumentListener() {
63     @Override
64     public void beforeDocumentChange(DocumentEvent e) {
65       if (!myEditor.getDocument().isInBulkUpdate()) {
66         cancelAnimatedScrolling(true);
67       }
68     }
69   };
70
71   private final ChangeListener myViewportChangeListener = new ChangeListener() {
72     private Rectangle myLastViewRect;
73
74     @Override
75     public void stateChanged(ChangeEvent event) {
76       Rectangle viewRect = getVisibleArea();
77       VisibleAreaEvent visibleAreaEvent = new VisibleAreaEvent(myEditor, myLastViewRect, viewRect);
78       if (!myViewportPositioned && viewRect.height > 0) {
79         myViewportPositioned = true;
80         if (adjustVerticalOffsetIfNecessary()) {
81           return;
82         }
83       }
84       myLastViewRect = viewRect;
85       for (VisibleAreaListener listener : myVisibleAreaListeners) {
86         listener.visibleAreaChanged(visibleAreaEvent);
87       }
88     }
89   };
90
91   public ScrollingModelImpl(EditorImpl editor) {
92     myEditor = editor;
93     myEditor.getScrollPane().getViewport().addChangeListener(myViewportChangeListener);
94     myEditor.getDocument().addDocumentListener(myDocumentListener);
95   }
96
97   /**
98    * Corrects viewport position if necessary on initial editor showing.
99    *
100    * @return {@code true} if the vertical viewport position has been adjusted; {@code false} otherwise
101    */
102   private boolean adjustVerticalOffsetIfNecessary() {
103     // There is a possible case that the editor is configured to show virtual space at file bottom and requested position is located
104     // somewhere around. We don't want to position viewport in a way that most of its area is used to represent that virtual empty space.
105     // So, we tweak vertical offset if necessary.
106     int maxY = Math.max(myEditor.getLineHeight(), myEditor.getDocument().getLineCount() * myEditor.getLineHeight());
107     int minPreferredY = maxY - getVisibleArea().height * 2 / 3;
108     final int currentOffset = getVerticalScrollOffset();
109     int offsetToUse = Math.min(minPreferredY, currentOffset);
110     if (offsetToUse != currentOffset) {
111       scroll(getHorizontalScrollOffset(), offsetToUse);
112       return true;
113     }
114     return false;
115   }
116
117   @NotNull
118   @Override
119   public Rectangle getVisibleArea() {
120     assertIsDispatchThread();
121     return myEditor.getScrollPane().getViewport().getViewRect();
122   }
123
124   @NotNull
125   @Override
126   public Rectangle getVisibleAreaOnScrollingFinished() {
127     assertIsDispatchThread();
128     if (SystemProperties.isTrueSmoothScrollingEnabled()) {
129       Rectangle viewRect = myEditor.getScrollPane().getViewport().getViewRect();
130       return new Rectangle(getOffset(getHorizontalScrollBar()), getOffset(getVerticalScrollBar()), viewRect.width, viewRect.height);
131     }
132     if (myCurrentAnimationRequest != null) {
133       return myCurrentAnimationRequest.getTargetVisibleArea();
134     }
135     return getVisibleArea();
136   }
137
138   @Override
139   public void scrollToCaret(@NotNull ScrollType scrollType) {
140     if (LOG.isDebugEnabled()) {
141       LOG.debug(new Throwable());
142     }
143     assertIsDispatchThread();
144     myEditor.validateSize();
145     AsyncEditorLoader.performWhenLoaded(myEditor, () -> scrollTo(myEditor.getCaretModel().getVisualPosition(), scrollType));
146   }
147
148   private void scrollTo(@NotNull VisualPosition pos, @NotNull ScrollType scrollType) {
149     Point targetLocation = myEditor.visualPositionToXY(pos);
150     scrollTo(targetLocation, scrollType);
151   }
152
153   private void scrollTo(@NotNull Point targetLocation, @NotNull ScrollType scrollType) {
154     AnimatedScrollingRunnable canceledThread = cancelAnimatedScrolling(false);
155     Rectangle viewRect = canceledThread != null ? canceledThread.getTargetVisibleArea() : getVisibleArea();
156     Point p = calcOffsetsToScroll(targetLocation, scrollType, viewRect);
157     scroll(p.x, p.y);
158   }
159
160   @Override
161   public void scrollTo(@NotNull LogicalPosition pos, @NotNull ScrollType scrollType) {
162     assertIsDispatchThread();
163
164     AsyncEditorLoader.performWhenLoaded(myEditor, () -> scrollTo(myEditor.logicalPositionToXY(pos), scrollType));
165   }
166
167   private static void assertIsDispatchThread() {
168     ApplicationManagerEx.getApplicationEx().assertIsDispatchThread();
169   }
170
171   @Override
172   public void runActionOnScrollingFinished(@NotNull Runnable action) {
173     assertIsDispatchThread();
174
175     if (myCurrentAnimationRequest != null) {
176       myCurrentAnimationRequest.addPostRunnable(action);
177       return;
178     }
179
180     action.run();
181   }
182
183   public boolean isAnimationEnabled() {
184     return !myAnimationDisabled;
185   }
186
187   @Override
188   public void disableAnimation() {
189     myAnimationDisabled = true;
190   }
191
192   @Override
193   public void enableAnimation() {
194     myAnimationDisabled = false;
195   }
196
197   private Point calcOffsetsToScroll(Point targetLocation, ScrollType scrollType, Rectangle viewRect) {
198     if (myEditor.getSettings().isRefrainFromScrolling() && viewRect.contains(targetLocation)) {
199       if (scrollType == ScrollType.CENTER ||
200           scrollType == ScrollType.CENTER_DOWN ||
201           scrollType == ScrollType.CENTER_UP) {
202         scrollType = ScrollType.RELATIVE;
203       }
204     }
205
206     int spaceWidth = EditorUtil.getSpaceWidth(Font.PLAIN, myEditor);
207     int xInsets = myEditor.getSettings().getAdditionalColumnsCount() * spaceWidth;
208
209     int hOffset = scrollType == ScrollType.CENTER ||
210                   scrollType == ScrollType.CENTER_DOWN ||
211                   scrollType == ScrollType.CENTER_UP ? 0 : viewRect.x;
212     if (targetLocation.x < hOffset) {
213       int inset = 4 * spaceWidth;
214       if (scrollType == ScrollType.MAKE_VISIBLE && targetLocation.x < viewRect.width - inset) {
215         // if we need to scroll to the left to make target position visible, 
216         // let's scroll to the leftmost position (if that will make caret visible)
217         hOffset = 0;
218       }
219       else {
220         hOffset = Math.max(0, targetLocation.x - inset);
221       }
222     }
223     else if (viewRect.width > 0 && targetLocation.x >= hOffset + viewRect.width) {
224       hOffset = targetLocation.x - Math.max(0, viewRect.width - xInsets);
225     }
226
227     // the following code tries to keeps 1 line above and 1 line below if available in viewRect
228     int lineHeight = myEditor.getLineHeight();
229     // to avoid 'hysteresis', minAcceptableY should be always less or equal to maxAcceptableY
230     int minAcceptableY = viewRect.y + Math.max(0, Math.min(lineHeight, viewRect.height - 3 * lineHeight));
231     int maxAcceptableY = viewRect.y + (viewRect.height <= lineHeight ? 0 :
232                                        (viewRect.height - (viewRect.height <= 2 * lineHeight ? lineHeight : 2 * lineHeight)));
233     int scrollUpBy = minAcceptableY - targetLocation.y;
234     int scrollDownBy = targetLocation.y - maxAcceptableY;
235     int centerPosition = targetLocation.y - viewRect.height / 3;
236
237     int vOffset = viewRect.y;
238     if (scrollType == ScrollType.CENTER) {
239       vOffset = centerPosition;
240     }
241     else if (scrollType == ScrollType.CENTER_UP) {
242       if (scrollUpBy > 0 || scrollDownBy > 0 || vOffset > centerPosition) {
243         vOffset = centerPosition;
244       }
245     }
246     else if (scrollType == ScrollType.CENTER_DOWN) {
247       if (scrollUpBy > 0 || scrollDownBy > 0 || vOffset < centerPosition) {
248         vOffset = centerPosition;
249       }
250     }
251     else if (scrollType == ScrollType.RELATIVE) {
252       if (scrollUpBy > 0) {
253         vOffset = viewRect.y - scrollUpBy;
254       }
255       else if (scrollDownBy > 0) {
256         vOffset = viewRect.y + scrollDownBy;
257       }
258     }
259     else if (scrollType == ScrollType.MAKE_VISIBLE) {
260       if (scrollUpBy > 0 || scrollDownBy > 0) {
261         vOffset = centerPosition;
262       }
263     }
264
265     JScrollPane scrollPane = myEditor.getScrollPane();
266     hOffset = Math.max(0, hOffset);
267     vOffset = Math.max(0, vOffset);
268     hOffset = Math.min(scrollPane.getHorizontalScrollBar().getMaximum() - getExtent(scrollPane.getHorizontalScrollBar()), hOffset);
269     vOffset = Math.min(scrollPane.getVerticalScrollBar().getMaximum() - getExtent(scrollPane.getVerticalScrollBar()), vOffset);
270
271     return new Point(hOffset, vOffset);
272   }
273
274   @Nullable
275   public JScrollBar getVerticalScrollBar() {
276     assertIsDispatchThread();
277     JScrollPane scrollPane = myEditor.getScrollPane();
278     return scrollPane.getVerticalScrollBar();
279   }
280
281   @Nullable
282   public JScrollBar getHorizontalScrollBar() {
283     assertIsDispatchThread();
284     return myEditor.getScrollPane().getHorizontalScrollBar();
285   }
286
287   @Override
288   public int getVerticalScrollOffset() {
289     return getOffset(getVerticalScrollBar());
290   }
291
292   @Override
293   public int getHorizontalScrollOffset() {
294     return getOffset(getHorizontalScrollBar());
295   }
296
297   private static int getOffset(JScrollBar scrollBar) {
298     return scrollBar == null ? 0 :
299            scrollBar instanceof Interpolable ? ((Interpolable)scrollBar).getTargetValue() : scrollBar.getValue();
300   }
301
302   private static int getExtent(JScrollBar scrollBar) {
303     return scrollBar == null ? 0 : scrollBar.getModel().getExtent();
304   }
305
306   @Override
307   public void scrollVertically(int scrollOffset) {
308     scroll(getHorizontalScrollOffset(), scrollOffset);
309   }
310
311   private void _scrollVertically(int scrollOffset) {
312     assertIsDispatchThread();
313
314     myEditor.validateSize();
315     JScrollBar scrollbar = myEditor.getScrollPane().getVerticalScrollBar();
316
317     scrollbar.setValue(scrollOffset);
318   }
319
320   @Override
321   public void scrollHorizontally(int scrollOffset) {
322     scroll(scrollOffset, getVerticalScrollOffset());
323   }
324
325   private void _scrollHorizontally(int scrollOffset) {
326     assertIsDispatchThread();
327
328     myEditor.validateSize();
329     JScrollBar scrollbar = myEditor.getScrollPane().getHorizontalScrollBar();
330     scrollbar.setValue(scrollOffset);
331   }
332
333   @Override
334   public void scroll(int hOffset, int vOffset) {
335     if (myAccumulateViewportChanges) {
336       myAccumulatedXOffset = hOffset;
337       myAccumulatedYOffset = vOffset;
338       return;
339     }
340
341     cancelAnimatedScrolling(false);
342
343     VisibleEditorsTracker editorsTracker = VisibleEditorsTracker.getInstance();
344     boolean useAnimation;
345     //System.out.println("myCurrentCommandStart - myLastCommandFinish = " + (myCurrentCommandStart - myLastCommandFinish));
346     if (!myEditor.getSettings().isAnimatedScrolling() || myAnimationDisabled || RemoteDesktopService.isRemoteSession()) {
347       useAnimation = false;
348     }
349     else if (CommandProcessor.getInstance().getCurrentCommand() == null) {
350       useAnimation = myEditor.getComponent().isShowing();
351     }
352     else if (editorsTracker.getCurrentCommandStart() - editorsTracker.getLastCommandFinish() <
353              AnimatedScrollingRunnable.SCROLL_DURATION) {
354       useAnimation = false;
355     }
356     else {
357       useAnimation = editorsTracker.wasEditorVisibleOnCommandStart(myEditor);
358     }
359
360     cancelAnimatedScrolling(false);
361
362     if (useAnimation) {
363       //System.out.println("scrollToAnimated: " + endVOffset);
364
365       int startHOffset = getHorizontalScrollOffset();
366       int startVOffset = getVerticalScrollOffset();
367
368       if (startHOffset == hOffset && startVOffset == vOffset) {
369         return;
370       }
371
372       //System.out.println("startVOffset = " + startVOffset);
373
374       try {
375         myCurrentAnimationRequest = new AnimatedScrollingRunnable(startHOffset, startVOffset, hOffset, vOffset);
376       }
377       catch (NoAnimationRequiredException e) {
378         _scrollHorizontally(hOffset);
379         _scrollVertically(vOffset);
380       }
381     }
382     else {
383       _scrollHorizontally(hOffset);
384       _scrollVertically(vOffset);
385     }
386   }
387
388   @Override
389   public void addVisibleAreaListener(@NotNull VisibleAreaListener listener) {
390     myVisibleAreaListeners.add(listener);
391   }
392
393   @Override
394   public void removeVisibleAreaListener(@NotNull VisibleAreaListener listener) {
395     boolean success = myVisibleAreaListeners.remove(listener);
396     LOG.assertTrue(success);
397   }
398
399   public void finishAnimation() {
400     cancelAnimatedScrolling(true);
401   }
402
403   @Nullable
404   private AnimatedScrollingRunnable cancelAnimatedScrolling(boolean scrollToTarget) {
405     AnimatedScrollingRunnable request = myCurrentAnimationRequest;
406     myCurrentAnimationRequest = null;
407     if (request != null) {
408       request.cancel(scrollToTarget);
409     }
410     return request;
411   }
412
413   public void dispose() {
414     myEditor.getDocument().removeDocumentListener(myDocumentListener);
415     myEditor.getScrollPane().getViewport().removeChangeListener(myViewportChangeListener);
416   }
417
418   public void beforeModalityStateChanged() {
419     cancelAnimatedScrolling(true);
420   }
421
422   public boolean isScrollingNow() {
423     return myCurrentAnimationRequest != null;
424   }
425
426   @Override
427   public void accumulateViewportChanges() {
428     myAccumulateViewportChanges = true;
429   }
430
431   @Override
432   public void flushViewportChanges() {
433     myAccumulateViewportChanges = false;
434     if (myAccumulatedXOffset >= 0 && myAccumulatedYOffset >= 0) {
435       scroll(myAccumulatedXOffset, myAccumulatedYOffset);
436       myAccumulatedXOffset = myAccumulatedYOffset = -1;
437       cancelAnimatedScrolling(true);
438     }
439   }
440
441   void onBulkDocumentUpdateStarted() {
442     cancelAnimatedScrolling(true);
443   }
444
445   private class AnimatedScrollingRunnable {
446     private static final int SCROLL_DURATION = 100;
447     private static final int SCROLL_INTERVAL = 10;
448
449     private final int myStartHOffset;
450     private final int myStartVOffset;
451     private final int myEndHOffset;
452     private final int myEndVOffset;
453     private final int myAnimationDuration;
454
455     private final ArrayList<Runnable> myPostRunnables = new ArrayList<>();
456
457     private final int myHDist;
458     private final int myVDist;
459     private final int myMaxDistToScroll;
460     private final double myTotalDist;
461     private final double myScrollDist;
462
463     private final int myStepCount;
464     private final double myPow;
465     private final Animator myAnimator;
466
467     public AnimatedScrollingRunnable(int startHOffset,
468                                      int startVOffset,
469                                      int endHOffset,
470                                      int endVOffset) throws NoAnimationRequiredException {
471       myStartHOffset = startHOffset;
472       myStartVOffset = startVOffset;
473       myEndHOffset = endHOffset;
474       myEndVOffset = endVOffset;
475
476       myHDist = Math.abs(myEndHOffset - myStartHOffset);
477       myVDist = Math.abs(myEndVOffset - myStartVOffset);
478
479       myMaxDistToScroll = myEditor.getLineHeight() * 50;
480       myTotalDist = Math.sqrt((double)myHDist * myHDist + (double)myVDist * myVDist);
481       myScrollDist = Math.min(myTotalDist, myMaxDistToScroll);
482       myAnimationDuration = calcAnimationDuration();
483       if (myAnimationDuration < SCROLL_INTERVAL * 2) {
484         throw new NoAnimationRequiredException();
485       }
486       myStepCount = myAnimationDuration / SCROLL_INTERVAL - 1;
487       double firstStepTime = 1.0 / myStepCount;
488       double firstScrollDist = 5.0;
489       if (myTotalDist > myScrollDist) {
490         firstScrollDist *= myTotalDist / myScrollDist;
491         firstScrollDist = Math.min(firstScrollDist, myEditor.getLineHeight() * 5);
492       }
493       myPow = myScrollDist > 0 ? setupPow(firstStepTime, firstScrollDist / myScrollDist) : 1;
494
495       myAnimator = new Animator("Animated scroller", myStepCount, SCROLL_DURATION, false, true) {
496         @Override
497         public void paintNow(int frame, int totalFrames, int cycle) {
498           double time = ((double)(frame + 1)) / (double)totalFrames;
499           double fraction = timeToFraction(time);
500
501           final int hOffset = (int)(myStartHOffset + (myEndHOffset - myStartHOffset) * fraction + 0.5);
502           final int vOffset = (int)(myStartVOffset + (myEndVOffset - myStartVOffset) * fraction + 0.5);
503
504           _scrollHorizontally(hOffset);
505           _scrollVertically(vOffset);
506         }
507
508         @Override
509         protected void paintCycleEnd() {
510           if (!isDisposed()) { // Animator will invoke paintCycleEnd() even if it was disposed
511             finish(true);
512           }
513         }
514       };
515
516       myAnimator.resume();
517     }
518
519     @NotNull
520     public Rectangle getTargetVisibleArea() {
521       Rectangle viewRect = getVisibleArea();
522       return new Rectangle(myEndHOffset, myEndVOffset, viewRect.width, viewRect.height);
523     }
524
525     public void cancel(boolean scrollToTarget) {
526       assertIsDispatchThread();
527       finish(scrollToTarget);
528     }
529
530     public void addPostRunnable(Runnable runnable) {
531       myPostRunnables.add(runnable);
532     }
533
534     private void finish(boolean scrollToTarget) {
535       if (scrollToTarget || !myPostRunnables.isEmpty()) {
536         _scrollHorizontally(myEndHOffset);
537         _scrollVertically(myEndVOffset);
538         executePostRunnables();
539       }
540
541       Disposer.dispose(myAnimator);
542       if (myCurrentAnimationRequest == this) {
543         myCurrentAnimationRequest = null;
544       }
545     }
546
547     private void executePostRunnables() {
548       for (Runnable runnable : myPostRunnables) {
549         runnable.run();
550       }
551     }
552
553     private double timeToFraction(double time) {
554       if (time > 0.5) {
555         return 1 - timeToFraction(1 - time);
556       }
557
558       double fraction = Math.pow(time * 2, myPow) / 2;
559
560       if (myTotalDist > myMaxDistToScroll) {
561         fraction *= (double)myMaxDistToScroll / myTotalDist;
562       }
563
564       return fraction;
565     }
566
567     private double setupPow(double inTime, double moveBy) {
568       double pow = Math.log(2 * moveBy) / Math.log(2 * inTime);
569       if (pow < 1) pow = 1;
570       return pow;
571     }
572
573     private int calcAnimationDuration() {
574       int lineHeight = myEditor.getLineHeight();
575       double lineDist = myTotalDist / lineHeight;
576       double part = (lineDist - 1) / 10;
577       if (part > 1) part = 1;
578       //System.out.println("duration = " + duration);
579       return (int)(part * SCROLL_DURATION);
580     }
581   }
582
583   private static class NoAnimationRequiredException extends Exception {
584   }
585 }