1# This file is part of ReText
2# Copyright: 2016 Maurice van der Pot
3#
4# This program is free software; you can redistribute it and/or modify
5# it under the terms of the GNU General Public License as published by
6# the Free Software Foundation; either version 2 of the License, or
7# (at your option) any later version.
8#
9# This program is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12# GNU General Public License for more details.
13#
14# You should have received a copy of the GNU General Public License
15# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16
17from PyQt5.QtCore import QPoint
18
19class SyncScroll:
20
21    def __init__(self, previewFrame,
22                       editorPositionToSourceLineFunc,
23                       sourceLineToEditorPositionFunc):
24        self.posmap = {}
25        self.frame = previewFrame
26        self.editorPositionToSourceLine = editorPositionToSourceLineFunc
27        self.sourceLineToEditorPosition = sourceLineToEditorPositionFunc
28
29        self.previewPositionBeforeLoad = QPoint()
30        self.contentIsLoading = False
31
32        self.editorViewportHeight = 0
33        self.editorViewportOffset = 0
34        self.editorCursorPosition = 0
35
36        self.frame.contentsSizeChanged.connect(self._handlePreviewResized)
37        self.frame.loadStarted.connect(self._handleLoadStarted)
38        self.frame.loadFinished.connect(self._handleLoadFinished)
39
40    def isActive(self):
41        return bool(self.posmap)
42
43    def handleEditorResized(self, editorViewportHeight):
44        self.editorViewportHeight = editorViewportHeight
45        self._updatePreviewScrollPosition()
46
47    def handleEditorScrolled(self, editorViewportOffset):
48        self.editorViewportOffset = editorViewportOffset
49        return self._updatePreviewScrollPosition()
50
51    def handleCursorPositionChanged(self, editorCursorPosition):
52        self.editorCursorPosition = editorCursorPosition
53        return self._updatePreviewScrollPosition()
54
55    def _handleLoadStarted(self):
56        # Store the current scroll position so it can be restored when the new
57        # content is presented
58        self.previewPositionBeforeLoad = self.frame.scrollPosition()
59        self.contentIsLoading = True
60
61    def _handleLoadFinished(self):
62        self.frame.setScrollPosition(self.previewPositionBeforeLoad)
63        self.contentIsLoading = False
64        self._recalculatePositionMap()
65
66    def _handlePreviewResized(self):
67        self._recalculatePositionMap()
68        self._updatePreviewScrollPosition()
69        if not self.posmap and self.frame.scrollPosition().y() == 0:
70            self.frame.setScrollPosition(self.previewPositionBeforeLoad)
71
72    def _linearScale(self, fromValue, fromMin, fromMax, toMin, toMax):
73        fromRange = fromMax - fromMin
74        toRange = toMax - toMin
75
76        toValue = toMin
77
78        if fromRange:
79            toValue += ((fromValue - fromMin) * toRange) / float(fromRange)
80
81        return toValue
82
83    def _updatePreviewScrollPosition(self):
84        if not self.posmap:
85            # Loading new content resets the scroll position to the top. If we
86            # don't have a posmap to calculate the new best position, then
87            # restore the position stored at the beginning of the load.
88            if self.contentIsLoading:
89                self.frame.setScrollPosition(self.previewPositionBeforeLoad)
90            return
91
92        textedit_pixel_to_scroll_to = self.editorCursorPosition
93
94        if textedit_pixel_to_scroll_to < self.editorViewportOffset:
95            textedit_pixel_to_scroll_to = self.editorViewportOffset
96
97        last_viewport_pixel = self.editorViewportOffset + self.editorViewportHeight
98        if textedit_pixel_to_scroll_to > last_viewport_pixel:
99            textedit_pixel_to_scroll_to = last_viewport_pixel
100
101        line_to_scroll_to = self.editorPositionToSourceLine(textedit_pixel_to_scroll_to)
102
103        # Do a binary search through the posmap to find the nearest line above
104        # and below the line to scroll to for which the rendered position is
105        # known.
106        posmap_lines = [0] + sorted(self.posmap.keys())
107        min_index = 0
108        max_index = len(posmap_lines) - 1
109        while max_index - min_index > 1:
110            current_index = int((min_index + max_index) / 2)
111            if posmap_lines[current_index] > line_to_scroll_to:
112                max_index = current_index
113            else:
114                min_index = current_index
115
116        # number of nearest line above and below for which we have a position
117        min_line = posmap_lines[min_index]
118        max_line = posmap_lines[max_index]
119
120        min_textedit_pos = self.sourceLineToEditorPosition(min_line)
121        max_textedit_pos = self.sourceLineToEditorPosition(max_line)
122
123        # rendered pixel position of nearest line above and below
124        min_preview_pos = self.posmap[min_line]
125        max_preview_pos = self.posmap[max_line]
126
127        # calculate rendered pixel position of line corresponding to cursor
128        # (0 == top of document)
129        preview_pixel_to_scroll_to = self._linearScale(textedit_pixel_to_scroll_to,
130                                                      min_textedit_pos, max_textedit_pos,
131                                                      min_preview_pos, max_preview_pos)
132
133        distance_to_top_of_viewport = textedit_pixel_to_scroll_to - self.editorViewportOffset
134        preview_scroll_offset = preview_pixel_to_scroll_to - distance_to_top_of_viewport
135
136        pos = self.frame.scrollPosition()
137        pos.setY(preview_scroll_offset)
138        self.frame.setScrollPosition(pos)
139
140    def _setPositionMap(self, posmap):
141        self.posmap = posmap
142        if posmap:
143            self.posmap[0] = 0
144
145    def _recalculatePositionMap(self):
146        if hasattr(self.frame, 'getPositionMap'):
147            # For WebEngine the update has to be asynchronous
148            self.frame.getPositionMap(self._setPositionMap)
149            return
150
151        # Create a list of input line positions mapped to vertical pixel positions in the preview
152        self.posmap = {}
153        elements = self.frame.findAllElements('[data-posmap]')
154
155        if elements:
156            # If there are posmap attributes, then build a posmap
157            # dictionary from them that will be used whenever the
158            # cursor is moved.
159            for el in elements:
160                value = el.attribute('data-posmap', 'invalid')
161                bottom = el.geometry().bottom()
162
163                # Ignore data-posmap entries that do not have integer values
164                try:
165                    self.posmap[int(value)] = bottom
166                except ValueError:
167                    pass
168
169            self.posmap[0] = 0
170
171