fix: replace scrollTo override with locked scrollbar to block all scroll

_LockedScrollBar subclasses QScrollBar and blocks setValue when locked.
This catches ALL scroll sources — Qt layout, auto-scroll, item changes —
not just scrollTo. Locked during setCurrentRow, visibility changes,
playlist checks, and video load (200ms after load to catch late events).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-14 00:00:58 +02:00
parent a6b91d9d3f
commit 96d4dd8d89
+24 -15
View File
@@ -20,7 +20,7 @@ from PyQt6.QtWidgets import (
QLabel, QPushButton, QLineEdit, QFileDialog, QFrame, QStatusBar, QLabel, QPushButton, QLineEdit, QFileDialog, QFrame, QStatusBar,
QListWidget, QListWidgetItem, QAbstractItemView, QSplitter, QToolTip, QListWidget, QListWidgetItem, QAbstractItemView, QSplitter, QToolTip,
QComboBox, QCheckBox, QSpinBox, QDoubleSpinBox, QComboBox, QCheckBox, QSpinBox, QDoubleSpinBox,
QMessageBox, QInputDialog, QMessageBox, QInputDialog, QScrollBar,
) )
from PyQt6.QtCore import Qt, QObject, QThread, QTimer, QRect, QSize, pyqtSignal, QSettings from PyQt6.QtCore import Qt, QObject, QThread, QTimer, QRect, QSize, pyqtSignal, QSettings
from PyQt6.QtGui import QPainter, QColor, QPen, QPixmap, QDragEnterEvent, QDropEvent, QCursor, QFont, QKeySequence, QShortcut from PyQt6.QtGui import QPainter, QColor, QPen, QPixmap, QDragEnterEvent, QDropEvent, QCursor, QFont, QKeySequence, QShortcut
@@ -1497,11 +1497,25 @@ class SnapPreviewWindow(QWidget):
self._in_dock = False self._in_dock = False
class _LockedScrollBar(QScrollBar):
"""Vertical scrollbar that ignores programmatic setValue when locked."""
def __init__(self, *args):
super().__init__(*args)
self.locked = False
def setValue(self, v: int) -> None:
if not self.locked:
super().setValue(v)
class PlaylistWidget(QListWidget): class PlaylistWidget(QListWidget):
file_selected = pyqtSignal(str) # emits full path of selected file file_selected = pyqtSignal(str) # emits full path of selected file
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self._locked_sb = _LockedScrollBar(Qt.Orientation.Vertical, self)
self.setVerticalScrollBar(self._locked_sb)
self.setDragDropMode(QAbstractItemView.DragDropMode.NoDragDrop) self.setDragDropMode(QAbstractItemView.DragDropMode.NoDragDrop)
self.setMinimumWidth(200) self.setMinimumWidth(200)
self.setAlternatingRowColors(True) self.setAlternatingRowColors(True)
@@ -1511,14 +1525,8 @@ class PlaylistWidget(QListWidget):
self._done_set: set[str] = set() # paths with exported clips self._done_set: set[str] = set() # paths with exported clips
self._hidden_basenames: set[str] = set() # profile-hidden basenames self._hidden_basenames: set[str] = set() # profile-hidden basenames
self._hide_exported = False self._hide_exported = False
self._scroll_locked = False
self.itemClicked.connect(self._on_item_clicked) self.itemClicked.connect(self._on_item_clicked)
def scrollTo(self, index, hint=QAbstractItemView.ScrollHint.EnsureVisible):
"""Block Qt's internal auto-scroll when the scroll is locked."""
if not self._scroll_locked:
super().scrollTo(index, hint)
def add_files(self, paths: list[str]) -> None: def add_files(self, paths: list[str]) -> None:
"""Append paths not already in queue; auto-select first if queue was empty.""" """Append paths not already in queue; auto-select first if queue was empty."""
was_empty = len(self._paths) == 0 was_empty = len(self._paths) == 0
@@ -1569,7 +1577,7 @@ class PlaylistWidget(QListWidget):
def _apply_visibility(self) -> None: def _apply_visibility(self) -> None:
"""Centralized: item is hidden if profile-hidden OR (hide_exported AND done).""" """Centralized: item is hidden if profile-hidden OR (hide_exported AND done)."""
self._scroll_locked = True self._locked_sb.locked = True
self.setUpdatesEnabled(False) self.setUpdatesEnabled(False)
for i, path in enumerate(self._paths): for i, path in enumerate(self._paths):
item = self.item(i) item = self.item(i)
@@ -1579,7 +1587,7 @@ class PlaylistWidget(QListWidget):
or (self._hide_exported and path in self._done_set)) or (self._hide_exported and path in self._done_set))
item.setHidden(hidden) item.setHidden(hidden)
self.setUpdatesEnabled(True) self.setUpdatesEnabled(True)
self._scroll_locked = False self._locked_sb.locked = False
def advance(self) -> None: def advance(self) -> None:
"""Move to next visible item in queue.""" """Move to next visible item in queue."""
@@ -1607,9 +1615,9 @@ class PlaylistWidget(QListWidget):
def _select(self, row: int) -> None: def _select(self, row: int) -> None:
prev = self.currentRow() prev = self.currentRow()
self._scroll_locked = True self._locked_sb.locked = True
self.setCurrentRow(row) self.setCurrentRow(row)
self._scroll_locked = False self._locked_sb.locked = False
if prev >= 0 and prev != row and self.item(prev): if prev >= 0 and prev != row and self.item(prev):
self._refresh_item_text(prev) self._refresh_item_text(prev)
item = self.item(row) item = self.item(row)
@@ -2258,7 +2266,7 @@ class MainWindow(QMainWindow):
self._lbl_file.setText(os.path.basename(path)) self._lbl_file.setText(os.path.basename(path))
self.setWindowTitle(f"8-cut — {os.path.basename(path)}") self.setWindowTitle(f"8-cut — {os.path.basename(path)}")
_log(f"Loading: {os.path.basename(path)}") _log(f"Loading: {os.path.basename(path)}")
self._playlist._scroll_locked = True self._playlist._locked_sb.locked = True
self._mpv.load(path) self._mpv.load(path)
# _after_load triggered by MpvWidget.file_loaded signal # _after_load triggered by MpvWidget.file_loaded signal
@@ -2288,7 +2296,8 @@ class MainWindow(QMainWindow):
self._spn_spread.setValue(float(self._settings.value("spread", "3.0"))) self._spn_spread.setValue(float(self._settings.value("spread", "3.0")))
self._preview_win.show() self._preview_win.show()
self._preview_timer.start() self._preview_timer.start()
self._playlist._scroll_locked = False # Unlock scrollbar after Qt finishes processing layout events from load.
QTimer.singleShot(200, lambda: setattr(self._playlist._locked_sb, 'locked', False))
# Run DB fuzzy match off the main thread — can be slow on large databases. # Run DB fuzzy match off the main thread — can be slow on large databases.
filename = os.path.basename(self._file_path) filename = os.path.basename(self._file_path)
@@ -2314,7 +2323,7 @@ class MainWindow(QMainWindow):
def _refresh_playlist_checks(self) -> None: def _refresh_playlist_checks(self) -> None:
"""Re-evaluate marks on every playlist item for the current profile.""" """Re-evaluate marks on every playlist item for the current profile."""
profile = self._profile profile = self._profile
self._playlist._scroll_locked = True self._playlist._locked_sb.locked = True
self._playlist.setUpdatesEnabled(False) self._playlist.setUpdatesEnabled(False)
for path in self._playlist._paths: for path in self._playlist._paths:
markers = self._db.get_markers(os.path.basename(path), profile) markers = self._db.get_markers(os.path.basename(path), profile)
@@ -2323,7 +2332,7 @@ class MainWindow(QMainWindow):
else: else:
self._playlist.unmark_done(path) self._playlist.unmark_done(path)
self._playlist.setUpdatesEnabled(True) self._playlist.setUpdatesEnabled(True)
self._playlist._scroll_locked = False self._playlist._locked_sb.locked = False
def _on_delete_marker(self, output_path: str) -> None: def _on_delete_marker(self, output_path: str) -> None:
deleted = self._db.delete_group(output_path) deleted = self._db.delete_group(output_path)