diff --git a/ReText/syncscroll.py b/ReText/syncscroll.py index b14b16ba..72ff5031 100644 --- a/ReText/syncscroll.py +++ b/ReText/syncscroll.py @@ -14,6 +14,9 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . +import time +from bisect import bisect_left + from PyQt6.QtCore import QPoint @@ -21,11 +24,14 @@ class SyncScroll: def __init__(self, previewFrame, editorPositionToSourceLineFunc, - sourceLineToEditorPositionFunc): + sourceLineToEditorPositionFunc, + setEditorScrollValueFunc=None): self.posmap = {} self.frame = previewFrame self.editorPositionToSourceLine = editorPositionToSourceLineFunc self.sourceLineToEditorPosition = sourceLineToEditorPositionFunc + # Optional callback to set the editor vertical scroll value (in pixels) + self._setEditorScrollValue = setEditorScrollValueFunc self.previewPositionBeforeLoad = QPoint() self.contentIsLoading = False @@ -34,6 +40,20 @@ def __init__(self, previewFrame, self.editorViewportOffset = 0 self.editorCursorPosition = 0 + # Guards to prevent recursive scroll feedback loops + self._updating_preview = False + self._updating_editor = False + + # Cached orderings for mapping between preview positions and source lines + self._posmap_lines = [] + self._preview_posmap = [] + self._preview_positions = [] + # Track preview scroll events triggered by editor updates to avoid + # reacting to them again and creating feedback loops. + self._preview_scroll_pending = None + self._preview_scroll_pending_time = 0.0 + self._preview_scroll_pending_count = 0 + self.frame.contentsSizeChanged.connect(self._handlePreviewResized) self.frame.loadStarted.connect(self._handleLoadStarted) self.frame.loadFinished.connect(self._handleLoadFinished) @@ -46,6 +66,10 @@ def handleEditorResized(self, editorViewportHeight): self._updatePreviewScrollPosition() def handleEditorScrolled(self, editorViewportOffset): + # If we are programmatically updating the editor due to preview scroll, + # ignore this event to avoid feedback loops. + if self._updating_editor: + return self.editorViewportOffset = editorViewportOffset return self._updatePreviewScrollPosition() @@ -82,6 +106,9 @@ def _linearScale(self, fromValue, fromMin, fromMax, toMin, toMax): return toValue def _updatePreviewScrollPosition(self): + self._preview_scroll_pending = None + self._preview_scroll_pending_time = 0.0 + self._preview_scroll_pending_count = 0 if not self.posmap: # Loading new content resets the scroll position to the top. If we # don't have a posmap to calculate the new best position, then @@ -104,11 +131,11 @@ def _updatePreviewScrollPosition(self): # Do a binary search through the posmap to find the nearest line above # and below the line to scroll to for which the rendered position is # known. - posmap_lines = [0] + sorted(self.posmap.keys()) + posmap_lines = self._posmap_lines min_index = 0 max_index = len(posmap_lines) - 1 while max_index - min_index > 1: - current_index = int((min_index + max_index) / 2) + current_index = (min_index + max_index) // 2 if posmap_lines[current_index] > line_to_scroll_to: max_index = current_index else: @@ -135,11 +162,104 @@ def _updatePreviewScrollPosition(self): distance_to_top_of_viewport_preview = distance_to_top_of_viewport_editor / self.frame.zoomFactor() preview_scroll_offset = preview_pixel_to_scroll_to - distance_to_top_of_viewport_preview + self._preview_scroll_pending = preview_scroll_offset + self._preview_scroll_pending_time = time.monotonic() + self._preview_scroll_pending_count = 2 pos = self.frame.scrollPosition() pos.setY(preview_scroll_offset) - self.frame.setScrollPosition(pos) + # Prevent preview→editor feedback while we adjust preview scroll + self._updating_preview = True + try: + self.frame.setScrollPosition(pos) + finally: + self._updating_preview = False def _setPositionMap(self, posmap): self.posmap = posmap if posmap: self.posmap[0] = 0 + if self.posmap: + self._posmap_lines = sorted(self.posmap.keys()) + preview_sorted = sorted(self.posmap.items(), key=lambda item: item[1]) + self._preview_posmap = [(preview, line) for line, preview in preview_sorted] + self._preview_positions = [preview for preview, _ in self._preview_posmap] + else: + self._posmap_lines = [] + self._preview_posmap = [] + self._preview_positions = [] + + def handlePreviewScrolled(self, previewScrollPosition): + """ + Update editor scroll position based on preview scroll position. + + previewScrollPosition can be either a QPointF/QPoint or a numeric Y value. + """ + if not self._setEditorScrollValue: + return + # Avoid reacting to our own preview updates + if self._updating_preview: + return + if not self.posmap: + return + + # Extract Y coordinate + try: + preview_y = previewScrollPosition.y() + except AttributeError: + preview_y = float(previewScrollPosition) + + if self._preview_scroll_pending_count: + # Ignore preview scroll events that we triggered ourselves shortly + # before, otherwise we end up driving the editor back to an older + # position because of out-of-order posmap entries. + if time.monotonic() - self._preview_scroll_pending_time <= 0.3: + self._preview_scroll_pending_count -= 1 + if self._preview_scroll_pending_count <= 0: + self._preview_scroll_pending = None + self._preview_scroll_pending_time = 0.0 + return + self._preview_scroll_pending = None + self._preview_scroll_pending_time = 0.0 + self._preview_scroll_pending_count = 0 + + if not self._preview_posmap: + return + + self._preview_scroll_pending = None + self._preview_scroll_pending_time = 0.0 + self._preview_scroll_pending_count = 0 + + if len(self._preview_posmap) == 1: + _, line = self._preview_posmap[0] + editor_scroll_to = self.sourceLineToEditorPosition(line) + else: + index = bisect_left(self._preview_positions, preview_y) + if index <= 0: + min_preview_pos, min_line = self._preview_posmap[0] + max_preview_pos, max_line = self._preview_posmap[1] + elif index >= len(self._preview_posmap): + min_preview_pos, min_line = self._preview_posmap[-2] + max_preview_pos, max_line = self._preview_posmap[-1] + else: + min_preview_pos, min_line = self._preview_posmap[index - 1] + max_preview_pos, max_line = self._preview_posmap[index] + + min_textedit_pos = self.sourceLineToEditorPosition(min_line) + max_textedit_pos = self.sourceLineToEditorPosition(max_line) + + editor_scroll_to = self._linearScale( + preview_y, + min_preview_pos, + max_preview_pos, + min_textedit_pos, + max_textedit_pos, + ) + + editor_scroll_value = int(editor_scroll_to) + + # Apply editor scroll with guard to avoid triggering editor→preview update + self._updating_editor = True + try: + self._setEditorScrollValue(editor_scroll_value) + finally: + self._updating_editor = False diff --git a/ReText/webenginepreview.py b/ReText/webenginepreview.py index fb3e11f7..41e6d96d 100644 --- a/ReText/webenginepreview.py +++ b/ReText/webenginepreview.py @@ -150,10 +150,13 @@ def __init__(self, tab, self.setPage(webPage) - self.syncscroll = SyncScroll(webPage, - editorPositionToSourceLineFunc, - sourceLineToEditorPositionFunc) self.editBox = tab.editBox + self.syncscroll = SyncScroll( + webPage, + editorPositionToSourceLineFunc, + sourceLineToEditorPositionFunc, + setEditorScrollValueFunc=self.editBox.verticalScrollBar().setValue, + ) settings = self.settings() settings.setDefaultTextEncoding('utf-8') @@ -172,6 +175,9 @@ def __init__(self, tab, self.editBox.verticalScrollBar().valueChanged.connect(self.syncscroll.handleEditorScrolled) self.editBox.resized.connect(self._handleEditorResized) + # When preview is scrolled, update the editor scroll accordingly + webPage.scrollPositionChanged.connect(self.syncscroll.handlePreviewScrolled) + # Scroll the preview when the mouse wheel is used to scroll # beyond the beginning/end of the editor self.editBox.scrollLimitReached.connect(self._handleWheelEvent) @@ -225,6 +231,8 @@ def disconnectExternalSignals(self): self.editBox.resized.disconnect(self._handleEditorResized) self.editBox.scrollLimitReached.disconnect(self._handleWheelEvent) + # Disconnect preview scroll synchronization + self.page().scrollPositionChanged.disconnect(self.syncscroll.handlePreviewScrolled) def _handleCursorPositionChanged(self): editorCursorPosition = self.editBox.verticalScrollBar().value() + \