From 251747bb0b5edeaea0d85caa6c0f526c2aad911f Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sat, 6 Jun 2026 16:13:26 +0200 Subject: [PATCH] feat: side-by-side pinned tabs + optional tab-name in export folder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Side-by-side: - Right-click a tab → "Show side-by-side" to pin it; 2+ pinned tabs render in a horizontal splitter (each with a name header + ✕ to unpin), via a QStackedWidget that swaps between the tab view and split view. - Tabs are now backed by self._pws (source of truth) so persistence and layout work regardless of where each list is parented; pinned state saved. - self._playlist resolves to the last-interacted pane (active pw), so exports and edits follow the pane you're acting in. Pinned flag persisted per profile. Tab → export folder (optional): - New "Tab→folder" checkbox: when on, the active tab's name is appended to the export folder (mp4 → mp4_BatchA). Default "List N" tabs are ignored. - _export_folder()/_export_base_name() helpers route exports, markers, next label, and delete/clear paths so they stay consistent with the tab folder. Co-Authored-By: Claude Opus 4.6 --- main.py | 279 +++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 227 insertions(+), 52 deletions(-) diff --git a/main.py b/main.py index 3ff2738..c8bbeaf 100755 --- a/main.py +++ b/main.py @@ -3134,8 +3134,12 @@ class SnapPreviewWindow(QWidget): class _PlaylistTabBar(QTabBar): - """Tab bar whose labels can be renamed by double-clicking.""" + """Tab bar whose labels can be renamed by double-clicking. + + Right-click a tab to pin/unpin it for the side-by-side view. + """ tab_renamed = pyqtSignal(int, str) + pin_toggle_requested = pyqtSignal(int) def mouseDoubleClickEvent(self, event): idx = self.tabAt(event.pos()) @@ -3144,6 +3148,26 @@ class _PlaylistTabBar(QTabBar): else: super().mouseDoubleClickEvent(event) + def contextMenuEvent(self, event): + idx = self.tabAt(event.pos()) + if idx < 0: + return + from PyQt6.QtWidgets import QMenu + menu = QMenu(self) + act_pin = menu.addAction("Show side-by-side") + act_pin.setCheckable(True) + pw = None + tw = self.parent() + if hasattr(tw, "widget"): + pw = tw.widget(idx) + act_pin.setChecked(bool(getattr(pw, "_pinned", False))) + act_rename = menu.addAction("Rename…") + chosen = menu.exec(event.globalPos()) + if chosen == act_pin: + self.pin_toggle_requested.emit(idx) + elif chosen == act_rename: + self._start_edit(idx) + def _start_edit(self, idx: int) -> None: editor = QLineEdit(self) editor.setText(self.tabText(idx)) @@ -3190,6 +3214,8 @@ class PlaylistWidget(QListWidget): self._all_subcat_counts: dict[str, int] = {} # profile-wide folder → count self._separators_before: set[str] = set() # paths that show a separator row above self._missing: set[str] = set() # paths not present on disk + self._pinned: bool = False # shown in the side-by-side view + self._label: str = "" # tab name (source of truth across views) self._visible: list[str | None] = [] # rows shown; None = separator row self._selected_path: str | None = None self.itemClicked.connect(self._on_item_clicked) @@ -3691,12 +3717,15 @@ class MainWindow(QMainWindow): # 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._pws: list[PlaylistWidget] = [] # source of truth for all lists + self._active_pw: "PlaylistWidget | None" = None # last-interacted list 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.tabBar().pin_toggle_requested.connect(self._on_pin_toggle) self._playlist_tabs.tabCloseRequested.connect(self._on_close_tab) self._playlist_tabs.currentChanged.connect(self._on_tab_changed) self._btn_add_tab = QPushButton("+") @@ -3705,6 +3734,15 @@ class MainWindow(QMainWindow): self._btn_add_tab.clicked.connect(lambda: self._add_playlist_tab()) self._playlist_tabs.setCornerWidget( self._btn_add_tab, Qt.Corner.TopRightCorner) + # Side-by-side container (shown when 2+ tabs are pinned). + self._split_container = QWidget() + self._split_layout = QHBoxLayout(self._split_container) + self._split_layout.setContentsMargins(0, 0, 0, 0) + self._split_layout.setSpacing(2) + from PyQt6.QtWidgets import QStackedWidget + self._list_stack = QStackedWidget() + self._list_stack.addWidget(self._playlist_tabs) # page 0: tabs + self._list_stack.addWidget(self._split_container) # page 1: side-by-side # Start with one empty tab; real contents loaded later via _load_playlist_tabs. self._add_playlist_tab("List 1", select=True) @@ -3795,6 +3833,16 @@ class MainWindow(QMainWindow): self._btn_folder.setFixedWidth(30) self._btn_folder.setToolTip("Browse for output folder") self._btn_folder.clicked.connect(self._pick_folder) + self._chk_tab_folder = QCheckBox("Tab→folder") + self._chk_tab_folder.setToolTip( + "When on, append the active tab's name to the export folder\n" + "(e.g. mp4 → mp4_BatchA) for files in that tab. Default 'List N'\n" + "tabs are ignored.") + self._chk_tab_folder.setChecked( + self._settings.value("tab_in_folder", "false") == "true") + self._chk_tab_folder.toggled.connect( + lambda v: self._settings.setValue("tab_in_folder", "true" if v else "false")) + self._chk_tab_folder.toggled.connect(self._on_tab_folder_toggled) self._spn_resize = QSpinBox() self._spn_resize.setRange(0, 4320) self._spn_resize.setSingleStep(64) @@ -4124,6 +4172,7 @@ class MainWindow(QMainWindow): path_row.addWidget(QLabel("Folder:")) path_row.addWidget(self._txt_folder, stretch=1) path_row.addWidget(self._btn_folder) + path_row.addWidget(self._chk_tab_folder) # Row 3 — video + encoding settings settings_row = QHBoxLayout() @@ -4203,7 +4252,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_tabs) + left_layout.addWidget(self._list_stack) # Scan results panel (right side) self._scan_panel = ScanResultsPanel(self._db) @@ -4408,15 +4457,44 @@ class MainWindow(QMainWindow): @property def _playlist(self) -> "PlaylistWidget": - """The PlaylistWidget of the currently active file-list tab.""" - return self._playlist_tabs.currentWidget() + """The active file list — the last-interacted pane/tab.""" + if self._active_pw is not None and self._active_pw in self._pws: + return self._active_pw + w = self._playlist_tabs.currentWidget() + if w is not None: + return w + return self._pws[0] if self._pws else None + + # ── Export folder (optionally tagged with the active tab name) ── + def _active_tab_name(self) -> str: + """Sanitized name of the active tab, or "" for default 'List N' tabs.""" + pw = self._playlist + label = "" if pw is None else (getattr(pw, "_label", "") or "") + label = label.strip().replace(" ", "_") + if not label or (label.startswith("List_") and label[5:].isdigit()): + return "" + return label + + def _export_folder(self) -> str: + """The export base folder, with the active tab name appended when enabled.""" + base = self._txt_folder.text() + if getattr(self, "_chk_tab_folder", None) and self._chk_tab_folder.isChecked(): + name = self._active_tab_name() + if name: + base = base.rstrip(os.sep) + "_" + name + return base + + def _export_base_name(self) -> str: + return os.path.basename(self._export_folder()) + + def _on_tab_folder_toggled(self, _checked: bool = False) -> None: + if self._file_path: + self._refresh_markers() + self._refresh_playlist_checks() + self._update_next_label() # ── 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() + def _wire_pw(self, pw: "PlaylistWidget") -> None: pw.file_selected.connect(self._load_file) pw.hide_requested.connect(self._on_hide_files) pw.unhide_requested.connect(self._on_unhide_files) @@ -4425,42 +4503,129 @@ class MainWindow(QMainWindow): pw.disable_all_requested.connect(self._disable_all_subcats) pw.enable_all_requested.connect(self._enable_all_subcats) 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) + + 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() + self._wire_pw(pw) + pw._label = label or f"List {len(self._pws) + 1}" + self._pws.append(pw) if separators: pw._separators_before = set(separators) if files: # Keep missing files so they're flagged in the list, not silently dropped. pw.add_files(files, allow_missing=True) - if select: - self._playlist_tabs.setCurrentIndex(idx) if not self._loading_tabs: + self._refresh_layout() + if select and not pw._pinned: + self._playlist_tabs.setCurrentWidget(pw) + self._active_pw = pw self._save_playlist_tabs() return pw + # ── Layout: tabs vs. side-by-side ──────────────────────────── + def _detach_all_pws(self) -> None: + for pw in self._pws: + pw.setParent(None) + + def _clear_split_container(self) -> None: + while self._split_layout.count(): + item = self._split_layout.takeAt(0) + w = item.widget() + if w is not None: + w.deleteLater() + + def _refresh_layout(self) -> None: + """Render self._pws either as tabs or, when 2+ are pinned, side-by-side.""" + pinned = [pw for pw in self._pws if pw._pinned] + prev = self._loading_tabs + self._loading_tabs = True + try: + self._detach_all_pws() + self._playlist_tabs.clear() + self._clear_split_container() + if len(pinned) >= 2: + for pw in self._pws: + if not pw._pinned: + self._playlist_tabs.addTab(pw, pw._label) + splitter = QSplitter(Qt.Orientation.Horizontal) + for pw in pinned: + panel = QWidget() + pl = QVBoxLayout(panel) + pl.setContentsMargins(0, 0, 0, 0) + pl.setSpacing(0) + hdr = QHBoxLayout() + lbl = QLabel(pw._label) + lbl.setStyleSheet("font-weight: bold; padding: 2px 4px;") + btn = QPushButton("✕") + btn.setFixedWidth(20) + btn.setToolTip("Remove from side-by-side") + btn.clicked.connect(lambda _=False, w=pw: self._on_unpin(w)) + hdr.addWidget(lbl, 1) + hdr.addWidget(btn) + pl.addLayout(hdr) + pl.addWidget(pw) + splitter.addWidget(panel) + self._split_layout.addWidget(splitter) + self._list_stack.setCurrentWidget(self._split_container) + else: + for pw in self._pws: + self._playlist_tabs.addTab(pw, pw._label) + self._list_stack.setCurrentWidget(self._playlist_tabs) + finally: + self._loading_tabs = prev + + def _on_pin_toggle(self, idx: int) -> None: + pw = self._playlist_tabs.widget(idx) + if pw is None: + return + pw._pinned = not pw._pinned + if pw._pinned and sum(1 for w in self._pws if w._pinned) < 2: + self._show_status("Pin another tab to show them side-by-side", 3500) + self._refresh_layout() + self._save_playlist_tabs() + + def _on_unpin(self, pw: "PlaylistWidget") -> None: + pw._pinned = False + self._refresh_layout() + self._save_playlist_tabs() + 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: + if self._loading_tabs: return - self._playlist.set_filter(self._playlist_filter.text()) + w = self._playlist_tabs.currentWidget() + if w is not None: + self._active_pw = w + w.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: + if len(self._pws) <= 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() + pw = self._playlist_tabs.widget(idx) + if pw is None or pw not in self._pws: + return + self._pws.remove(pw) + if self._active_pw is pw: + self._active_pw = None + pw.setParent(None) + pw.deleteLater() + self._refresh_layout() self._save_playlist_tabs() - def _on_tab_renamed(self, _idx: int, _text: str) -> None: + def _on_tab_renamed(self, idx: int, text: str) -> None: + pw = self._playlist_tabs.widget(idx) + if pw is not None: + pw._label = text self._save_playlist_tabs() def _playlist_tabs_key(self, profile: str | None = None) -> str: @@ -4470,26 +4635,28 @@ class MainWindow(QMainWindow): 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()} + tabs = [{ + "label": pw._label, + "files": list(pw._paths), + "separators": sorted(pw._separators_before), + "pinned": pw._pinned, + } for pw in self._pws] + cur = self._pws.index(self._active_pw) if self._active_pw in self._pws else 0 + data = {"tabs": tabs, "current": cur} 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 + self._active_pw = None try: - while self._playlist_tabs.count(): - w = self._playlist_tabs.widget(0) - self._playlist_tabs.removeTab(0) - w.deleteLater() + self._detach_all_pws() + self._playlist_tabs.clear() + self._clear_split_container() + for pw in self._pws: + pw.deleteLater() + self._pws = [] raw = self._settings.value(self._playlist_tabs_key(profile), "") data = None if raw: @@ -4497,6 +4664,7 @@ class MainWindow(QMainWindow): data = json.loads(raw) except (ValueError, TypeError): data = None + cur = 0 if not data or not data.get("tabs"): # Legacy fallback: one tab from old session_files/separators. p = profile or self._profile @@ -4506,20 +4674,23 @@ class MainWindow(QMainWindow): seps = [seps] if seps else [] self._add_playlist_tab( "List 1", files=list(files), - separators=seps, select=True) + separators=seps, select=False) else: for t in data["tabs"]: - self._add_playlist_tab( + pw = self._add_playlist_tab( t.get("label", "List"), files=list(t.get("files", [])), separators=t.get("separators", []), select=False) - cur = min(max(0, data.get("current", 0)), - self._playlist_tabs.count() - 1) - self._playlist_tabs.setCurrentIndex(cur) + pw._pinned = bool(t.get("pinned")) + cur = min(max(0, data.get("current", 0)), len(self._pws) - 1) finally: self._loading_tabs = False - if self._playlist is not None: - self._playlist.set_filter(self._playlist_filter.text()) + self._refresh_layout() + if self._pws: + self._active_pw = self._pws[cur] + if not self._active_pw._pinned: + self._playlist_tabs.setCurrentWidget(self._active_pw) + self._active_pw.set_filter(self._playlist_filter.text()) def _on_profile_activated(self, index: int) -> None: text = self._cmb_profile.itemText(index) @@ -4740,6 +4911,10 @@ class MainWindow(QMainWindow): def _load_file(self, path: str): if getattr(self, "_loading_tabs", False): return # ignore auto-selection while rebuilding tabs + # The list that emitted this becomes the active pane (side-by-side). + sender = self.sender() + if isinstance(sender, PlaylistWidget) and sender in self._pws: + self._active_pw = sender if not os.path.isfile(path): self._show_status(f"File not found: {os.path.basename(path)}", 5000) return @@ -4821,7 +4996,7 @@ class MainWindow(QMainWindow): # Run DB fuzzy match off the main thread — can be slow on large databases. filename = os.path.basename(self._file_path) self._db_worker = _DBWorker(self._db, filename, self._profile, - self._txt_folder.text()) + self._export_folder()) self._db_worker.result.connect(self._on_db_result) self._db_worker.start() @@ -4838,7 +5013,7 @@ class MainWindow(QMainWindow): def _refresh_markers(self) -> None: filename = os.path.basename(self._file_path) - folder = self._txt_folder.text() + folder = self._export_folder() markers = self._db.get_markers(filename, self._profile, folder) self._timeline.set_markers(markers) others = self._db.get_other_folder_markers( @@ -4850,7 +5025,7 @@ class MainWindow(QMainWindow): self._timeline.set_other_markers({}) return filename = os.path.basename(self._file_path) - folder = self._txt_folder.text() + folder = self._export_folder() others = self._db.get_other_folder_markers( filename, self._profile, folder) self._timeline.set_other_markers(others) @@ -4890,7 +5065,7 @@ class MainWindow(QMainWindow): if not deleted: self._db.delete_by_output_path(output_path) deleted = [output_path] - folder = self._txt_folder.text() + folder = self._export_folder() for path in deleted: if os.path.isdir(path): shutil.rmtree(path, ignore_errors=True) @@ -4920,7 +5095,7 @@ class MainWindow(QMainWindow): return filename = os.path.basename(self._file_path) markers = self._db.get_markers(filename, self._profile) - folder = self._txt_folder.text() + folder = self._export_folder() total_files = 0 for _, _, output_path, _ in markers: group = self._db.delete_group(output_path) @@ -5060,7 +5235,7 @@ class MainWindow(QMainWindow): if reply != QMessageBox.StandardButton.Yes: return # Delete all group clips from disk - folder = self._txt_folder.text() + folder = self._export_folder() for path in all_paths: if os.path.isdir(path): shutil.rmtree(path, ignore_errors=True) @@ -5832,7 +6007,7 @@ class MainWindow(QMainWindow): ) if reply != QMessageBox.StandardButton.Yes: return - folder = self._txt_folder.text() or "" + folder = self._export_folder() or "" vid_dirs: set[str] = set() for p in all_paths: if os.path.isdir(p): @@ -6255,7 +6430,7 @@ class MainWindow(QMainWindow): self._btn_auto_export.setEnabled(True) return - folder = self._txt_folder.text() + folder = self._export_folder() name = self._txt_name.text() or "clip" fmt = self._cmb_format.currentText() image_sequence = fmt == "WebP sequence" @@ -6471,7 +6646,7 @@ class MainWindow(QMainWindow): ) def _update_next_label(self): - folder = self._txt_folder.text() + folder = self._export_folder() name = self._txt_name.text() or "clip" vid_name = self._get_vid_folder(folder) vid_folder = os.path.join(folder, vid_name) @@ -6511,7 +6686,7 @@ class MainWindow(QMainWindow): fmt = self._cmb_format.currentText() image_sequence = fmt == "WebP sequence" - folder = self._txt_folder.text() + folder = self._export_folder() if folder_suffix: folder = folder.rstrip(os.sep) + "_" + folder_suffix os.makedirs(folder, exist_ok=True) @@ -6777,7 +6952,7 @@ class MainWindow(QMainWindow): if not groups: self._show_status("No manual exports to re-export") return - folder = self._txt_folder.text() + folder = self._export_folder() spread = self._spn_spread.value() clip_dur = self._clip_dur