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