From 0f335c5e66d524dd3fc135c4ab981d3301585e3f Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Fri, 5 Jun 2026 13:30:18 +0200 Subject: [PATCH] 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 --- main.py | 241 ++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 189 insertions(+), 52 deletions(-) diff --git a/main.py b/main.py index 779a4ae..9546ba6 100755 --- a/main.py +++ b/main.py @@ -17,7 +17,7 @@ from PyQt6.QtWidgets import ( QListWidget, QListWidgetItem, QAbstractItemView, QSplitter, QToolTip, QComboBox, QCheckBox, QSpinBox, QDoubleSpinBox, 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.QtGui import QPainter, QColor, QPen, QPixmap, QDragEnterEvent, QDropEvent, QCursor, QFont, QKeySequence, QShortcut @@ -3128,6 +3128,39 @@ class SnapPreviewWindow(QWidget): 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): file_selected = pyqtSignal(str) # emits full path of selected 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.setPlaceholderText("Filter…") self._playlist_filter.setClearButtonEnabled(True) - self._playlist = PlaylistWidget() - self._playlist_filter.textChanged.connect(self._playlist.set_filter) - self._playlist.file_selected.connect(self._load_file) - self._playlist.hide_requested.connect(self._on_hide_files) - self._playlist.unhide_requested.connect(self._on_unhide_files) - self._playlist.disable_requested.connect(self._on_disable_video) - self._playlist.enable_requested.connect(self._on_enable_video) - self._playlist.separators_changed.connect(self._save_separators) + self._playlist_filter.textChanged.connect(self._on_filter_changed) + + # Suppress tab persistence until _load_playlist_tabs runs at the end of + # __init__ (the profile combo it needs doesn't exist yet). + self._loading_tabs = True + self._playlist_tabs = QTabWidget() + self._playlist_tabs.setTabBar(_PlaylistTabBar()) + 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.file_loaded.connect(self._after_load) @@ -4104,7 +4150,7 @@ class MainWindow(QMainWindow): left_top.addWidget(self._btn_show_hidden) left_layout.addLayout(left_top) left_layout.addWidget(self._playlist_filter) - left_layout.addWidget(self._playlist) + left_layout.addWidget(self._playlist_tabs) # Scan results panel (right side) self._scan_panel = ScanResultsPanel(self._db) @@ -4175,22 +4221,14 @@ class MainWindow(QMainWindow): for key in ("?", "F1"): QShortcut(QKeySequence(key), self, context=ctx).activated.connect(self._show_shortcuts) - # Resume last session: reload previous playlist files (per-profile). - session_files = self._settings.value(f"session_files/{self._profile}", []) - 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() - if self._playlist.count() > 0: - self._playlist._select(0) - _log(f"Resumed session: {len(valid)} file(s)") + # Resume last session: rebuild file-list tabs (per-profile). + self._load_playlist_tabs() + self._apply_playlist_filters() + if self._playlist is not None and self._playlist.count() > 0: + self._playlist._select(0) # Apply persisted subcategory visibility to timeline + buttons. self._apply_subcat_visibility() - self._load_separators() self._show_changelog() @@ -4315,6 +4353,119 @@ class MainWindow(QMainWindow): return "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: text = self._cmb_profile.itemText(index) 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 is_dup: n = self._db.duplicate_profile(prev, name) - self._settings.setValue(f"session_files/{prev}", self._playlist._paths) - self._settings.setValue(f"session_files/{name}", list(self._playlist._paths)) + self._save_playlist_tabs(prev) + self._settings.setValue( + self._playlist_tabs_key(name), + self._settings.value(self._playlist_tabs_key(prev), "")) _log(f"Duplicated profile '{prev}' → '{name}' ({n} rows)") sentinel_idx = self._cmb_profile.count() - 3 self._cmb_profile.insertItem(sentinel_idx, name) @@ -4342,16 +4495,11 @@ class MainWindow(QMainWindow): self._cmb_profile.setCurrentIndex(idx) return text = name - # Save current profile's playlist before switching. - self._settings.setValue(f"session_files/{prev}", self._playlist._paths) + # Save current profile's tabs before switching. + self._save_playlist_tabs(prev) self._settings.setValue("profile", text) - # Load new profile's playlist. - new_files = self._settings.value(f"session_files/{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) + # Load new profile's tabs. + self._load_playlist_tabs(text) # Clear overwrite state — the selected marker belongs to the old profile if self._overwrite_path: self._overwrite_path = "" @@ -4365,7 +4513,6 @@ class MainWindow(QMainWindow): self._load_hidden_subcats() self._apply_subcat_visibility() self._apply_playlist_filters() - self._load_separators() self._refresh_scan_models() if self._playlist.count() > 0: self._playlist._select(0) @@ -4393,6 +4540,7 @@ class MainWindow(QMainWindow): return self._db.delete_profile(prev) self._settings.remove(f"session_files/{prev}") + self._settings.remove(self._playlist_tabs_key(prev)) _log(f"Deleted profile '{prev}' ({n} rows)") self._settings.setValue("profile", "default") self._populate_profile_combo() @@ -4532,8 +4680,11 @@ class MainWindow(QMainWindow): if paths: self._playlist.add_files(paths) self._apply_playlist_filters() + self._save_playlist_tabs() 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): self._show_status(f"File not found: {os.path.basename(path)}", 5000) return @@ -5382,21 +5533,6 @@ class MainWindow(QMainWindow): self._settings.setValue( 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: self._timeline._hidden_subcats = self._hidden_subcats self._timeline.update() @@ -6683,8 +6819,8 @@ class MainWindow(QMainWindow): def closeEvent(self, event): _log("Shutting down…") - # Save session playlist for resume (per-profile). - self._settings.setValue(f"session_files/{self._profile}", self._playlist._paths) + # Save file-list tabs for resume (per-profile). + self._save_playlist_tabs() # Cancel background workers to prevent callbacks into dead objects. self._cleanup_scan_worker() self._cleanup_train_worker() @@ -6742,6 +6878,7 @@ class MainWindow(QMainWindow): if paths: self._playlist.add_files(paths) self._apply_playlist_filters() + self._save_playlist_tabs() if __name__ == "__main__": main()