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 sys
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
@@ -47,6 +48,11 @@ def _icon(name: str) -> "QIcon":
|
|||||||
return QIcon(str(_ASSET_DIR / "icons" / name))
|
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"]
|
_SELVA_CATEGORIES = ["", "Human", "Animal", "Vehicle", "Tool", "Music", "Nature", "Sport", "Other"]
|
||||||
|
|
||||||
|
|
||||||
@@ -3296,6 +3302,7 @@ class _PlaylistTabBar(QTabBar):
|
|||||||
tab_renamed = pyqtSignal(int, str)
|
tab_renamed = pyqtSignal(int, str)
|
||||||
pin_toggle_requested = pyqtSignal(int)
|
pin_toggle_requested = pyqtSignal(int)
|
||||||
tab_folder_toggle_requested = pyqtSignal(int)
|
tab_folder_toggle_requested = pyqtSignal(int)
|
||||||
|
duplicate_requested = pyqtSignal(int)
|
||||||
|
|
||||||
def mouseDoubleClickEvent(self, event):
|
def mouseDoubleClickEvent(self, event):
|
||||||
idx = self.tabAt(event.pos())
|
idx = self.tabAt(event.pos())
|
||||||
@@ -3321,6 +3328,7 @@ class _PlaylistTabBar(QTabBar):
|
|||||||
act_tabfolder.setCheckable(True)
|
act_tabfolder.setCheckable(True)
|
||||||
act_tabfolder.setChecked(bool(getattr(pw, "_tab_folder", False)))
|
act_tabfolder.setChecked(bool(getattr(pw, "_tab_folder", False)))
|
||||||
act_rename = menu.addAction("Rename…")
|
act_rename = menu.addAction("Rename…")
|
||||||
|
act_dup = menu.addAction("Duplicate tab")
|
||||||
chosen = menu.exec(event.globalPos())
|
chosen = menu.exec(event.globalPos())
|
||||||
if chosen == act_pin:
|
if chosen == act_pin:
|
||||||
self.pin_toggle_requested.emit(idx)
|
self.pin_toggle_requested.emit(idx)
|
||||||
@@ -3328,6 +3336,8 @@ class _PlaylistTabBar(QTabBar):
|
|||||||
self.tab_folder_toggle_requested.emit(idx)
|
self.tab_folder_toggle_requested.emit(idx)
|
||||||
elif chosen == act_rename:
|
elif chosen == act_rename:
|
||||||
self._start_edit(idx)
|
self._start_edit(idx)
|
||||||
|
elif chosen == act_dup:
|
||||||
|
self.duplicate_requested.emit(idx)
|
||||||
|
|
||||||
def _start_edit(self, idx: int) -> None:
|
def _start_edit(self, idx: int) -> None:
|
||||||
editor = QLineEdit(self)
|
editor = QLineEdit(self)
|
||||||
@@ -3400,6 +3410,7 @@ class PlaylistWidget(QListWidget):
|
|||||||
self._missing: set[str] = set() # paths not present on disk
|
self._missing: set[str] = set() # paths not present on disk
|
||||||
self._pinned: bool = False # shown in the side-by-side view
|
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._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._label: str = "" # tab name (source of truth across views)
|
||||||
self._visible: list[str | None] = [] # rows shown; None = separator row
|
self._visible: list[str | None] = [] # rows shown; None = separator row
|
||||||
self._selected_path: str | None = None
|
self._selected_path: str | None = None
|
||||||
@@ -3922,6 +3933,9 @@ class MainWindow(QMainWindow):
|
|||||||
self._playlist_filter.setClearButtonEnabled(True)
|
self._playlist_filter.setClearButtonEnabled(True)
|
||||||
self._playlist_filter.textChanged.connect(self._on_filter_changed)
|
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
|
# Suppress tab persistence until _load_playlist_tabs runs at the end of
|
||||||
# __init__ (the profile combo it needs doesn't exist yet).
|
# __init__ (the profile combo it needs doesn't exist yet).
|
||||||
self._loading_tabs = True
|
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().pin_toggle_requested.connect(self._on_pin_toggle)
|
||||||
self._playlist_tabs.tabBar().tab_folder_toggle_requested.connect(
|
self._playlist_tabs.tabBar().tab_folder_toggle_requested.connect(
|
||||||
self._on_tab_folder_toggle)
|
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.tabCloseRequested.connect(self._on_close_tab)
|
||||||
self._playlist_tabs.currentChanged.connect(self._on_tab_changed)
|
self._playlist_tabs.currentChanged.connect(self._on_tab_changed)
|
||||||
self._btn_add_tab = QPushButton("+")
|
self._btn_add_tab = QPushButton("+")
|
||||||
@@ -4046,6 +4061,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._txt_folder.textChanged.connect(
|
self._txt_folder.textChanged.connect(
|
||||||
lambda v: self._settings.setValue("export_folder", v)
|
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 = QPushButton("...")
|
||||||
self._btn_folder.setFixedWidth(30)
|
self._btn_folder.setFixedWidth(30)
|
||||||
self._btn_folder.setToolTip("Browse for output folder")
|
self._btn_folder.setToolTip("Browse for output folder")
|
||||||
@@ -4930,6 +4946,28 @@ class MainWindow(QMainWindow):
|
|||||||
def _export_base_name(self) -> str:
|
def _export_base_name(self) -> str:
|
||||||
return os.path.basename(self._tab_export_folder())
|
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:
|
def _on_tab_folder_toggle(self, idx: int) -> None:
|
||||||
pw = self._playlist_tabs.widget(idx)
|
pw = self._playlist_tabs.widget(idx)
|
||||||
if pw is None:
|
if pw is None:
|
||||||
@@ -4941,6 +4979,34 @@ class MainWindow(QMainWindow):
|
|||||||
self._refresh_playlist_checks()
|
self._refresh_playlist_checks()
|
||||||
self._update_next_label()
|
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 ───────────────────────────────────────────
|
# ── File-list tabs ───────────────────────────────────────────
|
||||||
def _wire_pw(self, pw: "PlaylistWidget") -> None:
|
def _wire_pw(self, pw: "PlaylistWidget") -> None:
|
||||||
pw.file_selected.connect(self._load_file)
|
pw.file_selected.connect(self._load_file)
|
||||||
@@ -4958,6 +5024,10 @@ class MainWindow(QMainWindow):
|
|||||||
select: bool = True) -> "PlaylistWidget":
|
select: bool = True) -> "PlaylistWidget":
|
||||||
pw = PlaylistWidget()
|
pw = PlaylistWidget()
|
||||||
self._wire_pw(pw)
|
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}"
|
pw._label = label or f"List {len(self._pws) + 1}"
|
||||||
self._pws.append(pw)
|
self._pws.append(pw)
|
||||||
if separators:
|
if separators:
|
||||||
@@ -5193,6 +5263,7 @@ class MainWindow(QMainWindow):
|
|||||||
if w is not None:
|
if w is not None:
|
||||||
self._active_pw = w
|
self._active_pw = w
|
||||||
w.set_filter(self._playlist_filter.text())
|
w.set_filter(self._playlist_filter.text())
|
||||||
|
self._sync_folder_field_to_tab()
|
||||||
self._apply_playlist_filters()
|
self._apply_playlist_filters()
|
||||||
self._save_playlist_tabs()
|
self._save_playlist_tabs()
|
||||||
|
|
||||||
@@ -5230,6 +5301,7 @@ class MainWindow(QMainWindow):
|
|||||||
"separators": sorted(pw._separators_before),
|
"separators": sorted(pw._separators_before),
|
||||||
"pinned": pw._pinned,
|
"pinned": pw._pinned,
|
||||||
"tab_folder": pw._tab_folder,
|
"tab_folder": pw._tab_folder,
|
||||||
|
"export_folder": pw._dest_folder,
|
||||||
} for pw in self._pws]
|
} for pw in self._pws]
|
||||||
cur = self._pws.index(self._active_pw) if self._active_pw in self._pws else 0
|
cur = self._pws.index(self._active_pw) if self._active_pw in self._pws else 0
|
||||||
data = {"tabs": tabs, "current": cur}
|
data = {"tabs": tabs, "current": cur}
|
||||||
@@ -5273,6 +5345,8 @@ class MainWindow(QMainWindow):
|
|||||||
separators=t.get("separators", []), select=False)
|
separators=t.get("separators", []), select=False)
|
||||||
pw._pinned = bool(t.get("pinned"))
|
pw._pinned = bool(t.get("pinned"))
|
||||||
pw._tab_folder = bool(t.get("tab_folder"))
|
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)
|
cur = min(max(0, data.get("current", 0)), len(self._pws) - 1)
|
||||||
finally:
|
finally:
|
||||||
self._loading_tabs = False
|
self._loading_tabs = False
|
||||||
@@ -5282,6 +5356,7 @@ class MainWindow(QMainWindow):
|
|||||||
if not self._active_pw._pinned:
|
if not self._active_pw._pinned:
|
||||||
self._playlist_tabs.setCurrentWidget(self._active_pw)
|
self._playlist_tabs.setCurrentWidget(self._active_pw)
|
||||||
self._active_pw.set_filter(self._playlist_filter.text())
|
self._active_pw.set_filter(self._playlist_filter.text())
|
||||||
|
self._sync_folder_field_to_tab()
|
||||||
|
|
||||||
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)
|
||||||
@@ -5335,6 +5410,7 @@ class MainWindow(QMainWindow):
|
|||||||
self._playlist._select(0)
|
self._playlist._select(0)
|
||||||
self._refresh_markers()
|
self._refresh_markers()
|
||||||
self._update_status_perm()
|
self._update_status_perm()
|
||||||
|
self._sync_folder_field_to_tab()
|
||||||
_log(f"Profile switched: {text}")
|
_log(f"Profile switched: {text}")
|
||||||
self._show_status(f"Profile: {text}", 3000)
|
self._show_status(f"Profile: {text}", 3000)
|
||||||
|
|
||||||
@@ -5508,7 +5584,10 @@ class MainWindow(QMainWindow):
|
|||||||
return # ignore auto-selection while rebuilding tabs
|
return # ignore auto-selection while rebuilding tabs
|
||||||
# The list that emitted this becomes the active pane (side-by-side).
|
# The list that emitted this becomes the active pane (side-by-side).
|
||||||
sender = self.sender()
|
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
|
self._active_pw = sender
|
||||||
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)
|
||||||
@@ -7337,6 +7416,24 @@ class MainWindow(QMainWindow):
|
|||||||
folder = self._tab_export_folder()
|
folder = self._tab_export_folder()
|
||||||
if folder_suffix:
|
if folder_suffix:
|
||||||
folder = folder.rstrip(os.sep) + "_" + 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)
|
os.makedirs(folder, exist_ok=True)
|
||||||
spread = self._spn_spread.value()
|
spread = self._spn_spread.value()
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import pytest
|
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.
|
# A real platform is needed because MpvWidget creates a GL context.
|
||||||
# If construction fails for any environment reason, skip — this test is a
|
# If construction fails for any environment reason, skip — this test is a
|
||||||
# best-effort structural net, not a gate on core/ tests.
|
# 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
|
win._deck_loading = False
|
||||||
assert win._tab_crop._pinned is True
|
assert win._tab_crop._pinned is True
|
||||||
assert len(_split_columns(win)) == 3
|
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