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:
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user