Compare commits
5
Commits
cbbdfeadb1
...
1eb7de2a1a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1eb7de2a1a | ||
|
|
d7680283a2 | ||
|
|
bf4b6dad2d | ||
|
|
4715c0ce49 | ||
|
|
e5ce59c065 |
@@ -5,6 +5,7 @@ locale.setlocale(locale.LC_NUMERIC, "C") # required by libmpv before any import
|
||||
import sys
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
@@ -47,6 +48,11 @@ def _icon(name: str) -> "QIcon":
|
||||
return QIcon(str(_ASSET_DIR / "icons" / name))
|
||||
|
||||
|
||||
def _norm_token(s: str) -> str:
|
||||
"""Lowercase a string and strip everything but [a-z0-9] for fuzzy matching."""
|
||||
return re.sub(r"[^a-z0-9]", "", s.lower())
|
||||
|
||||
|
||||
_SELVA_CATEGORIES = ["", "Human", "Animal", "Vehicle", "Tool", "Music", "Nature", "Sport", "Other"]
|
||||
|
||||
|
||||
@@ -3296,6 +3302,7 @@ class _PlaylistTabBar(QTabBar):
|
||||
tab_renamed = pyqtSignal(int, str)
|
||||
pin_toggle_requested = pyqtSignal(int)
|
||||
tab_folder_toggle_requested = pyqtSignal(int)
|
||||
duplicate_requested = pyqtSignal(int)
|
||||
|
||||
def mouseDoubleClickEvent(self, event):
|
||||
idx = self.tabAt(event.pos())
|
||||
@@ -3321,6 +3328,7 @@ class _PlaylistTabBar(QTabBar):
|
||||
act_tabfolder.setCheckable(True)
|
||||
act_tabfolder.setChecked(bool(getattr(pw, "_tab_folder", False)))
|
||||
act_rename = menu.addAction("Rename…")
|
||||
act_dup = menu.addAction("Duplicate tab")
|
||||
chosen = menu.exec(event.globalPos())
|
||||
if chosen == act_pin:
|
||||
self.pin_toggle_requested.emit(idx)
|
||||
@@ -3328,6 +3336,8 @@ class _PlaylistTabBar(QTabBar):
|
||||
self.tab_folder_toggle_requested.emit(idx)
|
||||
elif chosen == act_rename:
|
||||
self._start_edit(idx)
|
||||
elif chosen == act_dup:
|
||||
self.duplicate_requested.emit(idx)
|
||||
|
||||
def _start_edit(self, idx: int) -> None:
|
||||
editor = QLineEdit(self)
|
||||
@@ -3400,6 +3410,7 @@ class PlaylistWidget(QListWidget):
|
||||
self._missing: set[str] = set() # paths not present on disk
|
||||
self._pinned: bool = False # shown in the side-by-side view
|
||||
self._tab_folder: bool = False # append this tab's name to export folder
|
||||
self._dest_folder: str = "" # per-tab export destination
|
||||
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
|
||||
@@ -3922,6 +3933,9 @@ class MainWindow(QMainWindow):
|
||||
self._playlist_filter.setClearButtonEnabled(True)
|
||||
self._playlist_filter.textChanged.connect(self._on_filter_changed)
|
||||
|
||||
# Guard against the textChanged→tab-save loop when we programmatically
|
||||
# sync _txt_folder to the active tab's stored export folder.
|
||||
self._syncing_folder = False
|
||||
# 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
|
||||
@@ -3936,6 +3950,7 @@ class MainWindow(QMainWindow):
|
||||
self._playlist_tabs.tabBar().pin_toggle_requested.connect(self._on_pin_toggle)
|
||||
self._playlist_tabs.tabBar().tab_folder_toggle_requested.connect(
|
||||
self._on_tab_folder_toggle)
|
||||
self._playlist_tabs.tabBar().duplicate_requested.connect(self._on_duplicate_tab)
|
||||
self._playlist_tabs.tabCloseRequested.connect(self._on_close_tab)
|
||||
self._playlist_tabs.currentChanged.connect(self._on_tab_changed)
|
||||
self._btn_add_tab = QPushButton("+")
|
||||
@@ -4046,6 +4061,7 @@ class MainWindow(QMainWindow):
|
||||
self._txt_folder.textChanged.connect(
|
||||
lambda v: self._settings.setValue("export_folder", v)
|
||||
)
|
||||
self._txt_folder.textChanged.connect(self._on_export_folder_edited)
|
||||
self._btn_folder = QPushButton("...")
|
||||
self._btn_folder.setFixedWidth(30)
|
||||
self._btn_folder.setToolTip("Browse for output folder")
|
||||
@@ -4930,6 +4946,28 @@ class MainWindow(QMainWindow):
|
||||
def _export_base_name(self) -> str:
|
||||
return os.path.basename(self._tab_export_folder())
|
||||
|
||||
def _on_export_folder_edited(self, text: str) -> None:
|
||||
"""User edited the folder field → store it on the active tab."""
|
||||
if self._syncing_folder:
|
||||
return
|
||||
pw = self._playlist
|
||||
if pw is not None:
|
||||
pw._dest_folder = text
|
||||
self._save_playlist_tabs()
|
||||
|
||||
def _sync_folder_field_to_tab(self) -> None:
|
||||
"""Reflect the active tab's stored export folder in the folder field."""
|
||||
pw = self._playlist
|
||||
if pw is None:
|
||||
return
|
||||
folder = getattr(pw, "_dest_folder", "") or self._settings.value(
|
||||
"export_folder", str(Path.home()))
|
||||
if folder != self._txt_folder.text():
|
||||
self._syncing_folder = True
|
||||
self._txt_folder.setText(folder)
|
||||
self._syncing_folder = False
|
||||
self._update_next_label()
|
||||
|
||||
def _on_tab_folder_toggle(self, idx: int) -> None:
|
||||
pw = self._playlist_tabs.widget(idx)
|
||||
if pw is None:
|
||||
@@ -4941,6 +4979,34 @@ class MainWindow(QMainWindow):
|
||||
self._refresh_playlist_checks()
|
||||
self._update_next_label()
|
||||
|
||||
def _on_duplicate_tab(self, idx: int) -> None:
|
||||
"""Clone a tab's file list into a new tab with an adapted name and its
|
||||
own (adapted) export folder. No files are moved or copied — the new tab
|
||||
just targets a separate dataset folder you export into."""
|
||||
src = self._playlist_tabs.widget(idx)
|
||||
if src is None:
|
||||
return
|
||||
base = f"{src._label} copy"
|
||||
label, n = base, 2
|
||||
existing = {pw._label for pw in self._pws}
|
||||
while label in existing:
|
||||
label = f"{base} {n}"
|
||||
n += 1
|
||||
pw = self._add_playlist_tab(
|
||||
label=label,
|
||||
files=list(src._paths),
|
||||
separators=sorted(src._separators_before),
|
||||
select=True,
|
||||
)
|
||||
src_folder = getattr(src, "_dest_folder", "")
|
||||
# rstrip the trailing separator so ".../AlexisCrystal/" + "_copy" becomes
|
||||
# a sibling ".../AlexisCrystal_copy", not a child ".../AlexisCrystal/_copy".
|
||||
pw._dest_folder = (src_folder.rstrip("/" + os.sep) + "_copy") if src_folder else ""
|
||||
pw._tab_folder = getattr(src, "_tab_folder", False)
|
||||
self._sync_folder_field_to_tab()
|
||||
self._save_playlist_tabs()
|
||||
self._show_status(f"Duplicated tab → {label}", 4000)
|
||||
|
||||
# ── File-list tabs ───────────────────────────────────────────
|
||||
def _wire_pw(self, pw: "PlaylistWidget") -> None:
|
||||
pw.file_selected.connect(self._load_file)
|
||||
@@ -4958,6 +5024,10 @@ class MainWindow(QMainWindow):
|
||||
select: bool = True) -> "PlaylistWidget":
|
||||
pw = PlaylistWidget()
|
||||
self._wire_pw(pw)
|
||||
# Inherit the current folder field (overwritten on load). _txt_folder may
|
||||
# not exist yet during the bootstrap tab built before widgets are wired.
|
||||
_fld = getattr(self, "_txt_folder", None)
|
||||
pw._dest_folder = _fld.text() if _fld is not None else ""
|
||||
pw._label = label or f"List {len(self._pws) + 1}"
|
||||
self._pws.append(pw)
|
||||
if separators:
|
||||
@@ -5193,6 +5263,7 @@ class MainWindow(QMainWindow):
|
||||
if w is not None:
|
||||
self._active_pw = w
|
||||
w.set_filter(self._playlist_filter.text())
|
||||
self._sync_folder_field_to_tab()
|
||||
self._apply_playlist_filters()
|
||||
self._save_playlist_tabs()
|
||||
|
||||
@@ -5230,6 +5301,7 @@ class MainWindow(QMainWindow):
|
||||
"separators": sorted(pw._separators_before),
|
||||
"pinned": pw._pinned,
|
||||
"tab_folder": pw._tab_folder,
|
||||
"export_folder": pw._dest_folder,
|
||||
} 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}
|
||||
@@ -5273,6 +5345,8 @@ class MainWindow(QMainWindow):
|
||||
separators=t.get("separators", []), select=False)
|
||||
pw._pinned = bool(t.get("pinned"))
|
||||
pw._tab_folder = bool(t.get("tab_folder"))
|
||||
pw._dest_folder = t.get("export_folder") or self._settings.value(
|
||||
"export_folder", str(Path.home()))
|
||||
cur = min(max(0, data.get("current", 0)), len(self._pws) - 1)
|
||||
finally:
|
||||
self._loading_tabs = False
|
||||
@@ -5282,6 +5356,7 @@ class MainWindow(QMainWindow):
|
||||
if not self._active_pw._pinned:
|
||||
self._playlist_tabs.setCurrentWidget(self._active_pw)
|
||||
self._active_pw.set_filter(self._playlist_filter.text())
|
||||
self._sync_folder_field_to_tab()
|
||||
|
||||
def _on_profile_activated(self, index: int) -> None:
|
||||
text = self._cmb_profile.itemText(index)
|
||||
@@ -5335,6 +5410,7 @@ class MainWindow(QMainWindow):
|
||||
self._playlist._select(0)
|
||||
self._refresh_markers()
|
||||
self._update_status_perm()
|
||||
self._sync_folder_field_to_tab()
|
||||
_log(f"Profile switched: {text}")
|
||||
self._show_status(f"Profile: {text}", 3000)
|
||||
|
||||
@@ -5508,7 +5584,10 @@ class MainWindow(QMainWindow):
|
||||
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:
|
||||
if isinstance(sender, PlaylistWidget) and sender in self._pws and sender is not self._active_pw:
|
||||
self._active_pw = sender
|
||||
self._sync_folder_field_to_tab()
|
||||
elif 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)
|
||||
@@ -7337,6 +7416,24 @@ class MainWindow(QMainWindow):
|
||||
folder = self._tab_export_folder()
|
||||
if folder_suffix:
|
||||
folder = folder.rstrip(os.sep) + "_" + folder_suffix
|
||||
|
||||
# Guardrail: warn if the loaded video's parent folder name doesn't
|
||||
# appear anywhere in the destination — likely a mismatched tab/folder.
|
||||
vid_parent = os.path.basename(os.path.dirname(self._file_path))
|
||||
vid_tok = _norm_token(vid_parent)
|
||||
folder_tokens = [_norm_token(p) for p in folder.split(os.sep) if p]
|
||||
if len(vid_tok) >= 3 and not any(vid_tok in ft for ft in folder_tokens):
|
||||
resp = QMessageBox.question(
|
||||
self, "Export folder mismatch",
|
||||
f"The loaded video is under:\n {vid_parent}\n\n"
|
||||
f"but you're exporting to:\n {folder}\n\nExport anyway?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No,
|
||||
)
|
||||
if resp != QMessageBox.StandardButton.Yes:
|
||||
self._show_status("Export cancelled (folder mismatch)", 4000)
|
||||
return
|
||||
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
spread = self._spn_spread.value()
|
||||
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
import pytest
|
||||
|
||||
# Redirect QSettings to a throwaway dir BEFORE any MainWindow is constructed, so
|
||||
# these GUI tests can never read or clobber the user's real ~/.config/8cut.conf
|
||||
# (constructing MainWindow loads — and on window close re-saves — the playlist
|
||||
# tabs; a test mutating tab state would otherwise persist into the real session).
|
||||
import tempfile as _tempfile
|
||||
from PyQt6.QtCore import QSettings as _QSettings
|
||||
_QS_DIR = _tempfile.mkdtemp(prefix="8cut-test-qs-")
|
||||
_QSettings.setPath(_QSettings.Format.NativeFormat, _QSettings.Scope.UserScope, _QS_DIR)
|
||||
_QSettings.setPath(_QSettings.Format.IniFormat, _QSettings.Scope.UserScope, _QS_DIR)
|
||||
|
||||
# A real platform is needed because MpvWidget creates a GL context.
|
||||
# If construction fails for any environment reason, skip — this test is a
|
||||
# best-effort structural net, not a gate on core/ tests.
|
||||
@@ -107,3 +117,23 @@ def test_side_by_side_menu_pins_third_panel(win):
|
||||
win._deck_loading = False
|
||||
assert win._tab_crop._pinned is True
|
||||
assert len(_split_columns(win)) == 3
|
||||
|
||||
|
||||
def test_duplicate_tab(win):
|
||||
# Right-click → Duplicate tab: clones files into a new tab with an adapted
|
||||
# name + adapted own folder, no file moves. Suppress QSettings writes via
|
||||
# _loading_tabs so the test can't touch the real session.
|
||||
win._loading_tabs = True
|
||||
try:
|
||||
src = win._pws[0]
|
||||
src._label = "AlexisCrystal"
|
||||
src._dest_folder = "/data/alexis/" # trailing slash, like real folders
|
||||
n_before = len(win._pws)
|
||||
win._on_duplicate_tab(win._playlist_tabs.indexOf(src))
|
||||
finally:
|
||||
win._loading_tabs = False
|
||||
assert len(win._pws) == n_before + 1
|
||||
dup = win._pws[-1]
|
||||
assert dup._label == "AlexisCrystal copy"
|
||||
# sibling, not a child: ".../alexis/" -> ".../alexis_copy" (not ".../alexis/_copy")
|
||||
assert dup._dest_folder == "/data/alexis_copy"
|
||||
|
||||
Reference in New Issue
Block a user