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
+87 -104
View File
@@ -20,7 +20,7 @@ from PyQt6.QtWidgets import (
QLabel, QPushButton, QLineEdit, QFileDialog, QFrame, QStatusBar,
QListWidget, QListWidgetItem, QAbstractItemView, QSplitter, QToolTip,
QComboBox, QCheckBox, QSpinBox, QDoubleSpinBox,
QMessageBox, QInputDialog, QScrollBar,
QMessageBox, QInputDialog,
)
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
@@ -1497,156 +1497,142 @@ class SnapPreviewWindow(QWidget):
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):
file_selected = pyqtSignal(str) # emits full path of selected file
def __init__(self):
super().__init__()
self._locked_sb = _LockedScrollBar(Qt.Orientation.Vertical, self)
self.setVerticalScrollBar(self._locked_sb)
self.setDragDropMode(QAbstractItemView.DragDropMode.NoDragDrop)
self.setMinimumWidth(200)
self.setAlternatingRowColors(True)
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._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._visible: list[str] = [] # paths currently shown in widget
self._selected_path: str | None = None
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:
"""Append paths not already in queue; auto-select first if queue was empty."""
was_empty = len(self._paths) == 0
self.setUpdatesEnabled(False)
for path in paths:
if path not in self._path_set and os.path.isfile(path):
self._paths.append(path)
self._path_set.add(path)
self.addItem(os.path.basename(path))
self.setUpdatesEnabled(True)
if was_empty and self._paths:
self._select_first_visible()
self._rebuild()
if was_empty and self._visible:
self._select(0)
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:
return
self._done_set.add(path)
row = self._paths.index(path)
self._done_counts[path] = n_clips
# Update in-place if visible, otherwise rebuild handles it.
if path in self._visible:
row = self._visible.index(path)
item = self.item(row)
if item is None:
return
if item:
name = os.path.basename(path)
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:
"""Remove the done mark and restore default color."""
if path not in self._path_set:
return
self._done_set.discard(path)
row = self._paths.index(path)
self._done_counts.pop(path, None)
if path in self._visible:
row = self._visible.index(path)
item = self.item(row)
if item is None:
return
if item:
item.setText(os.path.basename(path))
item.setForeground(QColor(200, 200, 200))
def set_hidden_basenames(self, basenames: set[str]) -> None:
"""Set the profile-hidden basenames and refresh visibility."""
self._hidden_basenames = basenames
self._apply_visibility()
self._rebuild()
def set_hide_exported(self, hide: bool) -> None:
self._hide_exported = hide
self._apply_visibility()
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
self._rebuild()
def advance(self) -> None:
"""Move to next visible item in queue."""
row = self.currentRow()
for r in range(row + 1, self.count()):
item = self.item(r)
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)
if row >= 0 and row < self.count() - 1:
self._select(row + 1)
def current_path(self) -> str | None:
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:
"""Select a row in the visible list."""
prev = self.currentRow()
self._locked_sb.locked = True
self.setCurrentRow(row)
self._locked_sb.locked = False
if prev >= 0 and prev != row and self.item(prev):
self._refresh_item_text(prev)
if prev >= 0 and prev != row:
self._decorate_prev(prev)
if 0 <= row < len(self._visible):
self._selected_path = self._visible[row]
self._decorate_current(row)
self.file_selected.emit(self._visible[row])
def _decorate_current(self, row: int) -> None:
item = self.item(row)
if item:
cur = item.text()
# 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 = ""
if not item:
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 ""
else:
tag = ""
item.setText(f"{tag}{os.path.basename(self._paths[row])}")
self.file_selected.emit(self._paths[row])
item.setText(f"{tag}{name}")
def _refresh_item_text(self, row: int) -> None:
def _decorate_prev(self, row: int) -> None:
item = self.item(row)
if item is None:
if not item or row >= len(self._visible):
return
name = os.path.basename(self._paths[row])
# Preserve the [N] prefix from mark_done if present.
cur = item.text()
if cur.startswith("[") and "] " in cur:
prefix = cur[:cur.index("] ") + 2]
item.setText(f"{prefix}{name}")
elif item.foreground().color() == QColor(100, 180, 100):
item.setText(f"{name}")
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:
item.setText(name)
@@ -1660,19 +1646,22 @@ class PlaylistWidget(QListWidget):
if item is None:
return
row = self.row(item)
if row >= len(self._visible):
return
path = self._visible[row]
from PyQt6.QtWidgets import QMenu
menu = QMenu(self)
name = os.path.basename(self._paths[row])
name = os.path.basename(path)
act_remove = menu.addAction(f"Remove: {name}")
act_hide = menu.addAction(f"Hide in profile: {name}")
chosen = menu.exec(event.globalPos())
if chosen == act_remove:
path = self._paths.pop(row)
self._paths.remove(path)
self._path_set.discard(path)
self._done_set.discard(path)
self.takeItem(row)
self._done_counts.pop(path, None)
self._rebuild()
elif chosen == act_hide:
path = self._paths[row]
self.hide_requested.emit(path)
@@ -2151,8 +2140,8 @@ class MainWindow(QMainWindow):
if valid:
self._playlist.add_files(valid)
self._apply_playlist_filters()
self._playlist._select_first_visible()
self._playlist.scrollToTop()
if self._playlist.count() > 0:
self._playlist._select(0)
_log(f"Resumed session: {len(valid)} file(s)")
def _show_shortcuts(self) -> None:
@@ -2246,7 +2235,7 @@ class MainWindow(QMainWindow):
basename = os.path.basename(path)
self._db.hide_file(basename, self._profile)
self._playlist._hidden_basenames.add(basename)
self._playlist._apply_visibility()
self._playlist._rebuild()
_log(f"Hidden file: {basename} in profile {self._profile}")
def _apply_playlist_filters(self) -> None:
@@ -2269,7 +2258,6 @@ class MainWindow(QMainWindow):
self._lbl_file.setText(os.path.basename(path))
self.setWindowTitle(f"8-cut — {os.path.basename(path)}")
_log(f"Loading: {os.path.basename(path)}")
self._playlist._locked_sb.locked = True
self._mpv.load(path)
# _after_load triggered by MpvWidget.file_loaded signal
@@ -2300,7 +2288,6 @@ class MainWindow(QMainWindow):
self._preview_win.show()
self._preview_timer.start()
# 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.
filename = os.path.basename(self._file_path)
@@ -2326,16 +2313,12 @@ class MainWindow(QMainWindow):
def _refresh_playlist_checks(self) -> None:
"""Re-evaluate marks on every playlist item for the current profile."""
profile = self._profile
self._playlist._locked_sb.locked = True
self._playlist.setUpdatesEnabled(False)
for path in self._playlist._paths:
markers = self._db.get_markers(os.path.basename(path), profile)
if markers:
self._playlist.mark_done(path, len(markers))
else:
self._playlist.unmark_done(path)
self._playlist.setUpdatesEnabled(True)
self._playlist._locked_sb.locked = False
def _on_delete_marker(self, output_path: str) -> None:
deleted = self._db.delete_group(output_path)