feat: tabbed file lists with editable labels

- Wrap the playlist in a QTabWidget; each tab is its own file list
- "+" corner button adds tabs; double-click a tab to rename inline; tabs are closable (last tab protected) and movable
- self._playlist now resolves to the active tab's PlaylistWidget
- Persist tabs (label + files + separators) per profile as JSON; falls back to legacy session_files/separators on first load
- Filter box and playlist filters apply to the active tab; tab switches reapply filters and refresh marks
- Profile switch/duplicate/delete now save/load/copy/remove per-profile tab state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-05 13:30:18 +02:00
parent f1f8fd5244
commit 0f335c5e66
+187 -50
View File
@@ -17,7 +17,7 @@ from PyQt6.QtWidgets import (
QListWidget, QListWidgetItem, QAbstractItemView, QSplitter, QToolTip, QListWidget, QListWidgetItem, QAbstractItemView, QSplitter, QToolTip,
QComboBox, QCheckBox, QSpinBox, QDoubleSpinBox, QComboBox, QCheckBox, QSpinBox, QDoubleSpinBox,
QMessageBox, QInputDialog, QDialog, QDialogButtonBox, QFormLayout, QMessageBox, QInputDialog, QDialog, QDialogButtonBox, QFormLayout,
QTableWidget, QTableWidgetItem, QTabWidget, QHeaderView, QTableWidget, QTableWidgetItem, QTabWidget, QTabBar, QHeaderView,
) )
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
@@ -3128,6 +3128,39 @@ class SnapPreviewWindow(QWidget):
self._in_dock = False self._in_dock = False
class _PlaylistTabBar(QTabBar):
"""Tab bar whose labels can be renamed by double-clicking."""
tab_renamed = pyqtSignal(int, str)
def mouseDoubleClickEvent(self, event):
idx = self.tabAt(event.pos())
if idx >= 0:
self._start_edit(idx)
else:
super().mouseDoubleClickEvent(event)
def _start_edit(self, idx: int) -> None:
editor = QLineEdit(self)
editor.setText(self.tabText(idx))
editor.selectAll()
editor.setGeometry(self.tabRect(idx))
editor.show()
editor.setFocus()
done = {"v": False}
def finish():
if done["v"]:
return
done["v"] = True
text = editor.text().strip()
editor.deleteLater()
if text:
self.setTabText(idx, text)
self.tab_renamed.emit(idx, text)
editor.editingFinished.connect(finish)
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
_SEP_END = "\x00END" # anchor for a separator after the last visible file _SEP_END = "\x00END" # anchor for a separator after the last visible file
@@ -3600,14 +3633,27 @@ class MainWindow(QMainWindow):
self._playlist_filter = QLineEdit() self._playlist_filter = QLineEdit()
self._playlist_filter.setPlaceholderText("Filter…") self._playlist_filter.setPlaceholderText("Filter…")
self._playlist_filter.setClearButtonEnabled(True) self._playlist_filter.setClearButtonEnabled(True)
self._playlist = PlaylistWidget() self._playlist_filter.textChanged.connect(self._on_filter_changed)
self._playlist_filter.textChanged.connect(self._playlist.set_filter)
self._playlist.file_selected.connect(self._load_file) # Suppress tab persistence until _load_playlist_tabs runs at the end of
self._playlist.hide_requested.connect(self._on_hide_files) # __init__ (the profile combo it needs doesn't exist yet).
self._playlist.unhide_requested.connect(self._on_unhide_files) self._loading_tabs = True
self._playlist.disable_requested.connect(self._on_disable_video) self._playlist_tabs = QTabWidget()
self._playlist.enable_requested.connect(self._on_enable_video) self._playlist_tabs.setTabBar(_PlaylistTabBar())
self._playlist.separators_changed.connect(self._save_separators) self._playlist_tabs.setTabsClosable(True)
self._playlist_tabs.setMovable(True)
self._playlist_tabs.setDocumentMode(True)
self._playlist_tabs.tabBar().tab_renamed.connect(self._on_tab_renamed)
self._playlist_tabs.tabCloseRequested.connect(self._on_close_tab)
self._playlist_tabs.currentChanged.connect(self._on_tab_changed)
self._btn_add_tab = QPushButton("+")
self._btn_add_tab.setFixedWidth(28)
self._btn_add_tab.setToolTip("Add a new file-list tab")
self._btn_add_tab.clicked.connect(lambda: self._add_playlist_tab())
self._playlist_tabs.setCornerWidget(
self._btn_add_tab, Qt.Corner.TopRightCorner)
# Start with one empty tab; real contents loaded later via _load_playlist_tabs.
self._add_playlist_tab("List 1", select=True)
self._mpv = MpvWidget() self._mpv = MpvWidget()
self._mpv.file_loaded.connect(self._after_load) self._mpv.file_loaded.connect(self._after_load)
@@ -4104,7 +4150,7 @@ class MainWindow(QMainWindow):
left_top.addWidget(self._btn_show_hidden) left_top.addWidget(self._btn_show_hidden)
left_layout.addLayout(left_top) left_layout.addLayout(left_top)
left_layout.addWidget(self._playlist_filter) left_layout.addWidget(self._playlist_filter)
left_layout.addWidget(self._playlist) left_layout.addWidget(self._playlist_tabs)
# Scan results panel (right side) # Scan results panel (right side)
self._scan_panel = ScanResultsPanel(self._db) self._scan_panel = ScanResultsPanel(self._db)
@@ -4175,22 +4221,14 @@ class MainWindow(QMainWindow):
for key in ("?", "F1"): for key in ("?", "F1"):
QShortcut(QKeySequence(key), self, context=ctx).activated.connect(self._show_shortcuts) QShortcut(QKeySequence(key), self, context=ctx).activated.connect(self._show_shortcuts)
# Resume last session: reload previous playlist files (per-profile). # Resume last session: rebuild file-list tabs (per-profile).
session_files = self._settings.value(f"session_files/{self._profile}", []) self._load_playlist_tabs()
if not session_files:
session_files = self._settings.value("session_files", [])
if session_files:
valid = [p for p in session_files if os.path.isfile(p)]
if valid:
self._playlist.add_files(valid)
self._apply_playlist_filters() self._apply_playlist_filters()
if self._playlist.count() > 0: if self._playlist is not None and self._playlist.count() > 0:
self._playlist._select(0) self._playlist._select(0)
_log(f"Resumed session: {len(valid)} file(s)")
# Apply persisted subcategory visibility to timeline + buttons. # Apply persisted subcategory visibility to timeline + buttons.
self._apply_subcat_visibility() self._apply_subcat_visibility()
self._load_separators()
self._show_changelog() self._show_changelog()
@@ -4315,6 +4353,119 @@ class MainWindow(QMainWindow):
return "default" return "default"
return text.strip() or "default" return text.strip() or "default"
@property
def _playlist(self) -> "PlaylistWidget":
"""The PlaylistWidget of the currently active file-list tab."""
return self._playlist_tabs.currentWidget()
# ── File-list tabs ───────────────────────────────────────────
def _add_playlist_tab(self, label: str | None = None,
files: list[str] | None = None,
separators: list[str] | None = None,
select: bool = True) -> "PlaylistWidget":
pw = PlaylistWidget()
pw.file_selected.connect(self._load_file)
pw.hide_requested.connect(self._on_hide_files)
pw.unhide_requested.connect(self._on_unhide_files)
pw.disable_requested.connect(self._on_disable_video)
pw.enable_requested.connect(self._on_enable_video)
pw.separators_changed.connect(self._save_playlist_tabs)
if label is None:
label = f"List {self._playlist_tabs.count() + 1}"
idx = self._playlist_tabs.addTab(pw, label)
if separators:
pw._separators_before = set(separators)
if files:
pw.add_files([p for p in files if os.path.isfile(p)])
if select:
self._playlist_tabs.setCurrentIndex(idx)
if not self._loading_tabs:
self._save_playlist_tabs()
return pw
def _on_filter_changed(self, text: str) -> None:
pw = self._playlist
if pw is not None:
pw.set_filter(text)
def _on_tab_changed(self, _idx: int) -> None:
if self._loading_tabs or self._playlist is None:
return
self._playlist.set_filter(self._playlist_filter.text())
self._apply_playlist_filters()
self._save_playlist_tabs()
def _on_close_tab(self, idx: int) -> None:
if self._playlist_tabs.count() <= 1:
self._show_status("Can't close the last tab", 3000)
return
w = self._playlist_tabs.widget(idx)
self._playlist_tabs.removeTab(idx)
w.deleteLater()
self._save_playlist_tabs()
def _on_tab_renamed(self, _idx: int, _text: str) -> None:
self._save_playlist_tabs()
def _playlist_tabs_key(self, profile: str | None = None) -> str:
return f"playlist_tabs/{profile or self._profile}"
def _save_playlist_tabs(self, profile: str | None = None) -> None:
if self._loading_tabs:
return
import json
tabs = []
for i in range(self._playlist_tabs.count()):
pw = self._playlist_tabs.widget(i)
tabs.append({
"label": self._playlist_tabs.tabText(i),
"files": list(pw._paths),
"separators": sorted(pw._separators_before),
})
data = {"tabs": tabs, "current": self._playlist_tabs.currentIndex()}
self._settings.setValue(self._playlist_tabs_key(profile), json.dumps(data))
def _load_playlist_tabs(self, profile: str | None = None) -> None:
"""Rebuild all file-list tabs for *profile* from settings."""
import json
self._loading_tabs = True
try:
while self._playlist_tabs.count():
w = self._playlist_tabs.widget(0)
self._playlist_tabs.removeTab(0)
w.deleteLater()
raw = self._settings.value(self._playlist_tabs_key(profile), "")
data = None
if raw:
try:
data = json.loads(raw)
except (ValueError, TypeError):
data = None
if not data or not data.get("tabs"):
# Legacy fallback: one tab from old session_files/separators.
p = profile or self._profile
files = self._settings.value(f"session_files/{p}", []) or []
seps = self._settings.value(f"separators/{p}", []) or []
if isinstance(seps, str):
seps = [seps] if seps else []
self._add_playlist_tab(
"List 1",
files=[f for f in files if os.path.isfile(f)],
separators=seps, select=True)
else:
for t in data["tabs"]:
self._add_playlist_tab(
t.get("label", "List"),
files=[f for f in t.get("files", []) if os.path.isfile(f)],
separators=t.get("separators", []), select=False)
cur = min(max(0, data.get("current", 0)),
self._playlist_tabs.count() - 1)
self._playlist_tabs.setCurrentIndex(cur)
finally:
self._loading_tabs = False
if self._playlist is not None:
self._playlist.set_filter(self._playlist_filter.text())
def _on_profile_activated(self, index: int) -> None: def _on_profile_activated(self, index: int) -> None:
text = self._cmb_profile.itemText(index) text = self._cmb_profile.itemText(index)
prev = self._settings.value("profile", "default") prev = self._settings.value("profile", "default")
@@ -4330,8 +4481,10 @@ class MainWindow(QMainWindow):
if ok and name and name not in self._PROFILE_SENTINELS: if ok and name and name not in self._PROFILE_SENTINELS:
if is_dup: if is_dup:
n = self._db.duplicate_profile(prev, name) n = self._db.duplicate_profile(prev, name)
self._settings.setValue(f"session_files/{prev}", self._playlist._paths) self._save_playlist_tabs(prev)
self._settings.setValue(f"session_files/{name}", list(self._playlist._paths)) self._settings.setValue(
self._playlist_tabs_key(name),
self._settings.value(self._playlist_tabs_key(prev), ""))
_log(f"Duplicated profile '{prev}''{name}' ({n} rows)") _log(f"Duplicated profile '{prev}''{name}' ({n} rows)")
sentinel_idx = self._cmb_profile.count() - 3 sentinel_idx = self._cmb_profile.count() - 3
self._cmb_profile.insertItem(sentinel_idx, name) self._cmb_profile.insertItem(sentinel_idx, name)
@@ -4342,16 +4495,11 @@ class MainWindow(QMainWindow):
self._cmb_profile.setCurrentIndex(idx) self._cmb_profile.setCurrentIndex(idx)
return return
text = name text = name
# Save current profile's playlist before switching. # Save current profile's tabs before switching.
self._settings.setValue(f"session_files/{prev}", self._playlist._paths) self._save_playlist_tabs(prev)
self._settings.setValue("profile", text) self._settings.setValue("profile", text)
# Load new profile's playlist. # Load new profile's tabs.
new_files = self._settings.value(f"session_files/{text}", []) self._load_playlist_tabs(text)
self._playlist.clear_all()
if new_files:
valid = [p for p in new_files if os.path.isfile(p)]
if valid:
self._playlist.add_files(valid)
# Clear overwrite state — the selected marker belongs to the old profile # Clear overwrite state — the selected marker belongs to the old profile
if self._overwrite_path: if self._overwrite_path:
self._overwrite_path = "" self._overwrite_path = ""
@@ -4365,7 +4513,6 @@ class MainWindow(QMainWindow):
self._load_hidden_subcats() self._load_hidden_subcats()
self._apply_subcat_visibility() self._apply_subcat_visibility()
self._apply_playlist_filters() self._apply_playlist_filters()
self._load_separators()
self._refresh_scan_models() self._refresh_scan_models()
if self._playlist.count() > 0: if self._playlist.count() > 0:
self._playlist._select(0) self._playlist._select(0)
@@ -4393,6 +4540,7 @@ class MainWindow(QMainWindow):
return return
self._db.delete_profile(prev) self._db.delete_profile(prev)
self._settings.remove(f"session_files/{prev}") self._settings.remove(f"session_files/{prev}")
self._settings.remove(self._playlist_tabs_key(prev))
_log(f"Deleted profile '{prev}' ({n} rows)") _log(f"Deleted profile '{prev}' ({n} rows)")
self._settings.setValue("profile", "default") self._settings.setValue("profile", "default")
self._populate_profile_combo() self._populate_profile_combo()
@@ -4532,8 +4680,11 @@ class MainWindow(QMainWindow):
if paths: if paths:
self._playlist.add_files(paths) self._playlist.add_files(paths)
self._apply_playlist_filters() self._apply_playlist_filters()
self._save_playlist_tabs()
def _load_file(self, path: str): def _load_file(self, path: str):
if getattr(self, "_loading_tabs", False):
return # ignore auto-selection while rebuilding tabs
if not os.path.isfile(path): if not os.path.isfile(path):
self._show_status(f"File not found: {os.path.basename(path)}", 5000) self._show_status(f"File not found: {os.path.basename(path)}", 5000)
return return
@@ -5382,21 +5533,6 @@ class MainWindow(QMainWindow):
self._settings.setValue( self._settings.setValue(
self._hidden_subcats_key(), sorted(self._hidden_subcats)) self._hidden_subcats_key(), sorted(self._hidden_subcats))
def _separators_key(self) -> str:
return f"separators/{self._profile}"
def _load_separators(self) -> None:
"""Load and apply this profile's playlist separators from settings."""
raw = self._settings.value(self._separators_key(), [])
if isinstance(raw, str):
raw = [raw] if raw else []
self._playlist._separators_before = set(raw or [])
self._playlist._rebuild()
def _save_separators(self) -> None:
self._settings.setValue(
self._separators_key(), sorted(self._playlist._separators_before))
def _apply_subcat_visibility(self) -> None: def _apply_subcat_visibility(self) -> None:
self._timeline._hidden_subcats = self._hidden_subcats self._timeline._hidden_subcats = self._hidden_subcats
self._timeline.update() self._timeline.update()
@@ -6683,8 +6819,8 @@ class MainWindow(QMainWindow):
def closeEvent(self, event): def closeEvent(self, event):
_log("Shutting down…") _log("Shutting down…")
# Save session playlist for resume (per-profile). # Save file-list tabs for resume (per-profile).
self._settings.setValue(f"session_files/{self._profile}", self._playlist._paths) self._save_playlist_tabs()
# Cancel background workers to prevent callbacks into dead objects. # Cancel background workers to prevent callbacks into dead objects.
self._cleanup_scan_worker() self._cleanup_scan_worker()
self._cleanup_train_worker() self._cleanup_train_worker()
@@ -6742,6 +6878,7 @@ class MainWindow(QMainWindow):
if paths: if paths:
self._playlist.add_files(paths) self._playlist.add_files(paths)
self._apply_playlist_filters() self._apply_playlist_filters()
self._save_playlist_tabs()
if __name__ == "__main__": if __name__ == "__main__":
main() main()