fix: rewrite playlist to never use hidden items, eliminating scroll bugs

Instead of hiding QListWidget items (which causes Qt to miscalculate
scroll position), the playlist now rebuilds its contents from scratch
whenever visibility changes. Only visible items exist in the widget.

- _rebuild() clears and repopulates from _paths filtered by visibility
- mark_done/unmark_done update in-place for visible items
- set_hidden_basenames and set_hide_exported trigger _rebuild
- Removes _LockedScrollBar, all scroll hacks, and workarounds

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-14 00:09:07 +02:00
parent ab5c8ae3db
commit 39e7b19bc5
+100 -117
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, QScrollBar, QMessageBox, QInputDialog,
) )
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,156 +1497,142 @@ 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 self.locked:
super().setValue(0)
else:
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)
self.setTextElideMode(Qt.TextElideMode.ElideMiddle) self.setTextElideMode(Qt.TextElideMode.ElideMiddle)
self._paths: list[str] = [] self._paths: list[str] = [] # all paths (full list)
self._path_set: set[str] = set() # O(1) duplicate check self._path_set: set[str] = set() # O(1) duplicate check
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._done_counts: dict[str, int] = {} # path → clip count
self._hidden_basenames: set[str] = set()
self._hide_exported = False self._hide_exported = False
self._visible: list[str] = [] # paths currently shown in widget
self._selected_path: str | None = None
self.itemClicked.connect(self._on_item_clicked) self.itemClicked.connect(self._on_item_clicked)
def _is_visible(self, path: str) -> bool:
if os.path.basename(path) in self._hidden_basenames:
return False
if self._hide_exported and path in self._done_set:
return False
return True
def _rebuild(self) -> None:
"""Rebuild the QListWidget from scratch with only visible items."""
self.blockSignals(True)
self.clear()
self._visible = [p for p in self._paths if self._is_visible(p)]
for path in self._visible:
name = os.path.basename(path)
if path in self._done_set:
n = self._done_counts.get(path, 0)
tag = f"[{n}]" if n else ""
item = QListWidgetItem(f"{tag} {name}")
item.setForeground(QColor(100, 180, 100))
else:
item = QListWidgetItem(name)
self.addItem(item)
# Restore selection.
if self._selected_path and self._selected_path in self._visible:
row = self._visible.index(self._selected_path)
self.setCurrentRow(row)
self._decorate_current(row)
self.blockSignals(False)
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."""
was_empty = len(self._paths) == 0 was_empty = len(self._paths) == 0
self.setUpdatesEnabled(False)
for path in paths: for path in paths:
if path not in self._path_set and os.path.isfile(path): if path not in self._path_set and os.path.isfile(path):
self._paths.append(path) self._paths.append(path)
self._path_set.add(path) self._path_set.add(path)
self.addItem(os.path.basename(path)) self._rebuild()
self.setUpdatesEnabled(True) if was_empty and self._visible:
if was_empty and self._paths: self._select(0)
self._select_first_visible()
def mark_done(self, path: str, n_clips: int = 0) -> None: def mark_done(self, path: str, n_clips: int = 0) -> None:
"""Gray out and show clip count on the queue item for path."""
if path not in self._path_set: if path not in self._path_set:
return return
self._done_set.add(path) self._done_set.add(path)
row = self._paths.index(path) self._done_counts[path] = n_clips
item = self.item(row) # Update in-place if visible, otherwise rebuild handles it.
if item is None: if path in self._visible:
return row = self._visible.index(path)
name = os.path.basename(path) item = self.item(row)
tag = f"[{n_clips}]" if n_clips else "" if item:
item.setText(f"{tag} {name}") name = os.path.basename(path)
item.setForeground(QColor(100, 180, 100)) tag = f"[{n_clips}]" if n_clips else ""
item.setText(f"{tag} {name}")
item.setForeground(QColor(100, 180, 100))
def unmark_done(self, path: str) -> None: def unmark_done(self, path: str) -> None:
"""Remove the done mark and restore default color."""
if path not in self._path_set: if path not in self._path_set:
return return
self._done_set.discard(path) self._done_set.discard(path)
row = self._paths.index(path) self._done_counts.pop(path, None)
item = self.item(row) if path in self._visible:
if item is None: row = self._visible.index(path)
return item = self.item(row)
item.setText(os.path.basename(path)) if item:
item.setForeground(QColor(200, 200, 200)) item.setText(os.path.basename(path))
item.setForeground(QColor(200, 200, 200))
def set_hidden_basenames(self, basenames: set[str]) -> None: def set_hidden_basenames(self, basenames: set[str]) -> None:
"""Set the profile-hidden basenames and refresh visibility."""
self._hidden_basenames = basenames self._hidden_basenames = basenames
self._apply_visibility() self._rebuild()
def set_hide_exported(self, hide: bool) -> None: def set_hide_exported(self, hide: bool) -> None:
self._hide_exported = hide self._hide_exported = hide
self._apply_visibility() self._rebuild()
def _apply_visibility(self) -> None:
"""Centralized: item is hidden if profile-hidden OR (hide_exported AND done)."""
self._locked_sb.locked = True
self.setUpdatesEnabled(False)
for i, path in enumerate(self._paths):
item = self.item(i)
if item is None:
continue
hidden = (os.path.basename(path) in self._hidden_basenames
or (self._hide_exported and path in self._done_set))
item.setHidden(hidden)
self.setUpdatesEnabled(True)
self._locked_sb.locked = False
def advance(self) -> None: def advance(self) -> None:
"""Move to next visible item in queue."""
row = self.currentRow() row = self.currentRow()
for r in range(row + 1, self.count()): if row >= 0 and row < self.count() - 1:
item = self.item(r) self._select(row + 1)
if item and not item.isHidden():
self._select(r)
return
def _select_first_visible(self) -> None:
"""Select the first non-hidden item, or item 0 if none hidden."""
for r in range(self.count()):
item = self.item(r)
if item and not item.isHidden():
self._select(r)
return
# Fallback: select first item regardless.
if self.count() > 0:
self._select(0)
def current_path(self) -> str | None: def current_path(self) -> str | None:
row = self.currentRow() row = self.currentRow()
return self._paths[row] if 0 <= row < len(self._paths) else None return self._visible[row] if 0 <= row < len(self._visible) else None
def _select(self, row: int) -> None: def _select(self, row: int) -> None:
"""Select a row in the visible list."""
prev = self.currentRow() prev = self.currentRow()
self._locked_sb.locked = True
self.setCurrentRow(row) self.setCurrentRow(row)
self._locked_sb.locked = False if prev >= 0 and prev != row:
if prev >= 0 and prev != row and self.item(prev): self._decorate_prev(prev)
self._refresh_item_text(prev) if 0 <= row < len(self._visible):
item = self.item(row) self._selected_path = self._visible[row]
if item: self._decorate_current(row)
cur = item.text() self.file_selected.emit(self._visible[row])
# Preserve [N] tag from mark_done.
if cur.startswith("[") and "] " in cur:
tag = cur[:cur.index("] ") + 2]
elif item.foreground().color() == QColor(100, 180, 100):
tag = ""
else:
tag = ""
item.setText(f"{tag}{os.path.basename(self._paths[row])}")
self.file_selected.emit(self._paths[row])
def _refresh_item_text(self, row: int) -> None: def _decorate_current(self, row: int) -> None:
item = self.item(row) item = self.item(row)
if item is None: if not item:
return return
name = os.path.basename(self._paths[row]) path = self._visible[row]
# Preserve the [N] prefix from mark_done if present. name = os.path.basename(path)
cur = item.text() if path in self._done_set:
if cur.startswith("[") and "] " in cur: n = self._done_counts.get(path, 0)
prefix = cur[:cur.index("] ") + 2] tag = f"[{n}] " if n else ""
item.setText(f"{prefix}{name}") else:
elif item.foreground().color() == QColor(100, 180, 100): tag = ""
item.setText(f" {name}") item.setText(f" {tag}{name}")
def _decorate_prev(self, row: int) -> None:
item = self.item(row)
if not item or row >= len(self._visible):
return
path = self._visible[row]
name = os.path.basename(path)
if path in self._done_set:
n = self._done_counts.get(path, 0)
tag = f"[{n}] " if n else ""
item.setText(f"{tag}{name}")
else: else:
item.setText(name) item.setText(name)
@@ -1660,19 +1646,22 @@ class PlaylistWidget(QListWidget):
if item is None: if item is None:
return return
row = self.row(item) row = self.row(item)
if row >= len(self._visible):
return
path = self._visible[row]
from PyQt6.QtWidgets import QMenu from PyQt6.QtWidgets import QMenu
menu = QMenu(self) menu = QMenu(self)
name = os.path.basename(self._paths[row]) name = os.path.basename(path)
act_remove = menu.addAction(f"Remove: {name}") act_remove = menu.addAction(f"Remove: {name}")
act_hide = menu.addAction(f"Hide in profile: {name}") act_hide = menu.addAction(f"Hide in profile: {name}")
chosen = menu.exec(event.globalPos()) chosen = menu.exec(event.globalPos())
if chosen == act_remove: if chosen == act_remove:
path = self._paths.pop(row) self._paths.remove(path)
self._path_set.discard(path) self._path_set.discard(path)
self._done_set.discard(path) self._done_set.discard(path)
self.takeItem(row) self._done_counts.pop(path, None)
self._rebuild()
elif chosen == act_hide: elif chosen == act_hide:
path = self._paths[row]
self.hide_requested.emit(path) self.hide_requested.emit(path)
@@ -2151,8 +2140,8 @@ class MainWindow(QMainWindow):
if valid: if valid:
self._playlist.add_files(valid) self._playlist.add_files(valid)
self._apply_playlist_filters() self._apply_playlist_filters()
self._playlist._select_first_visible() if self._playlist.count() > 0:
self._playlist.scrollToTop() self._playlist._select(0)
_log(f"Resumed session: {len(valid)} file(s)") _log(f"Resumed session: {len(valid)} file(s)")
def _show_shortcuts(self) -> None: def _show_shortcuts(self) -> None:
@@ -2246,7 +2235,7 @@ class MainWindow(QMainWindow):
basename = os.path.basename(path) basename = os.path.basename(path)
self._db.hide_file(basename, self._profile) self._db.hide_file(basename, self._profile)
self._playlist._hidden_basenames.add(basename) self._playlist._hidden_basenames.add(basename)
self._playlist._apply_visibility() self._playlist._rebuild()
_log(f"Hidden file: {basename} in profile {self._profile}") _log(f"Hidden file: {basename} in profile {self._profile}")
def _apply_playlist_filters(self) -> None: def _apply_playlist_filters(self) -> None:
@@ -2269,7 +2258,6 @@ 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._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
@@ -2300,7 +2288,6 @@ class MainWindow(QMainWindow):
self._preview_win.show() self._preview_win.show()
self._preview_timer.start() self._preview_timer.start()
# Unlock scrollbar after Qt finishes processing layout events from load. # 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)
@@ -2326,16 +2313,12 @@ 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._locked_sb.locked = True
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)
if markers: if markers:
self._playlist.mark_done(path, len(markers)) self._playlist.mark_done(path, len(markers))
else: else:
self._playlist.unmark_done(path) self._playlist.unmark_done(path)
self._playlist.setUpdatesEnabled(True)
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)