Files
HS-Rename/gui/main_window.py
T
Bulk RenamerandCursor 87282b0259 Release v1.0.14: stop passing match results through Qt signals.
Store worker results on the thread object, defer preview refresh, and log Python errors to crash.log to prevent native Qt crashes after TheTVDB match.

Co-authored-by: Cursor <[email protected]>
2026-07-03 20:51:20 -05:00

436 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Main window: directory picker, file list, rule selector + stack, preview table, Apply.
"""
import os
from pathlib import Path
from PyQt6.QtWidgets import (
QMainWindow,
QWidget,
QVBoxLayout,
QHBoxLayout,
QSplitter,
QLabel,
QPushButton,
QLineEdit,
QTableWidget,
QTableWidgetItem,
QFileDialog,
QScrollArea,
QGroupBox,
QMessageBox,
QHeaderView,
QAbstractItemView,
QFrame,
QCheckBox,
QComboBox,
)
from PyQt6.QtCore import Qt, QDir, QItemSelectionModel, QSettings, QTimer
from PyQt6.QtGui import QFont, QColor
from engine.pipeline import compute_preview, perform_renames, save_undo_log, load_undo_log, perform_undo
from engine.file_scan import FILE_TYPE_PRESETS, list_files, parse_extension_filter, file_extension
from engine.rules import Rule
from .rule_widgets import (
ReplaceRuleWidget,
RegexRuleWidget,
InsertRuleWidget,
RemoveRuleWidget,
CaseRuleWidget,
NumberingRuleWidget,
EpisodeRenumberRuleWidget,
TvdbEpisodeRenumberRuleWidget,
PrefixSuffixRuleWidget,
CsvMappingRuleWidget,
)
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("HSRename")
self.resize(1000, 700)
self._base_dir = ""
self._file_names: list[str] = []
self._preview_by_orig: dict[str, str] = {} # original name -> new name for selection preview
self._split: QSplitter | None = None
self._preview_refresh_scheduled = False
self._setup_ui()
self._restore_layout()
self._refresh_preview()
def _setup_ui(self):
central = QWidget()
self.setCentralWidget(central)
layout = QVBoxLayout(central)
# Top: directory bar
dir_layout = QHBoxLayout()
self.dir_edit = QLineEdit()
self.dir_edit.setPlaceholderText("Select a folder or enter path...")
self.dir_edit.textChanged.connect(self._on_dir_changed)
browse_btn = QPushButton("Browse…")
browse_btn.clicked.connect(self._browse)
dir_layout.addWidget(QLabel("Folder:"))
dir_layout.addWidget(self.dir_edit, 1)
dir_layout.addWidget(browse_btn)
layout.addLayout(dir_layout)
filter_layout = QHBoxLayout()
self.recursive_cb = QCheckBox("Include subfolders")
self.recursive_cb.toggled.connect(self._reload_files)
filter_layout.addWidget(self.recursive_cb)
filter_layout.addWidget(QLabel("File types:"))
self.types_preset = QComboBox()
self.types_preset.addItems(list(FILE_TYPE_PRESETS.keys()) + ["Custom"])
self.types_preset.currentTextChanged.connect(self._on_types_preset_changed)
filter_layout.addWidget(self.types_preset)
self.types_edit = QLineEdit()
self.types_edit.setPlaceholderText("e.g. .mkv,.mp4 or *.avi (empty = all types)")
self.types_edit.setReadOnly(True)
self.types_edit.editingFinished.connect(self._reload_files)
filter_layout.addWidget(self.types_edit, 1)
layout.addLayout(filter_layout)
# Split: left = rules, right = file list + preview
split = QSplitter(Qt.Orientation.Horizontal)
# Left: all rule panels at once (enable and configure multiple rules)
left = QWidget()
left_layout = QVBoxLayout(left)
left_layout.addWidget(QLabel("Rename rules (applied in order; enable and configure any combination):"))
self._rule_widgets = [
ReplaceRuleWidget(),
RegexRuleWidget(),
InsertRuleWidget(),
RemoveRuleWidget(),
CaseRuleWidget(),
NumberingRuleWidget(),
EpisodeRenumberRuleWidget(),
TvdbEpisodeRenumberRuleWidget(),
PrefixSuffixRuleWidget(),
CsvMappingRuleWidget(),
]
rule_titles = [
"1. Replace",
"2. Regex",
"3. Insert",
"4. Remove",
"5. Case",
"6. Numbering",
"7. Episode renumber",
"8. TheTVDB episode match",
"9. Prefix / Suffix",
"10. CSV mapping",
]
for title, w in zip(rule_titles, self._rule_widgets):
w.enabled_cb.toggled.connect(self._refresh_preview) # always refresh when checkbox toggled
w.ruleChanged.connect(self._on_rule_changed) # refresh only when enabled rules options change
if hasattr(w, "set_file_names"):
w.set_file_names(self._file_names)
if hasattr(w, "matchCompleted"):
w.matchCompleted.connect(self._schedule_refresh_preview)
g = QGroupBox(title)
g_layout = QVBoxLayout(g)
g_layout.setContentsMargins(8, 12, 8, 8)
g_layout.addWidget(w)
left_layout.addWidget(g)
left_layout.addStretch(1)
scroll_left = QScrollArea()
scroll_left.setWidget(left)
scroll_left.setWidgetResizable(True)
scroll_left.setFrameShape(QFrame.Shape.NoFrame)
scroll_left.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
split.addWidget(scroll_left)
# Right: file list + preview table
right = QWidget()
right_layout = QVBoxLayout(right)
right_layout.addWidget(QLabel("Preview (Original → New name):"))
self.preview_table = QTableWidget()
self.preview_table.setColumnCount(3)
self.preview_table.setHorizontalHeaderLabels(["Original name", "New name", "File type"])
self.preview_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
self.preview_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.preview_table.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.preview_table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
self.preview_table.setSortingEnabled(True)
self.preview_table.itemSelectionChanged.connect(self._on_preview_selection_changed)
right_layout.addWidget(self.preview_table, 1)
self.preview_status = QLabel("Add a folder to see files.")
right_layout.addWidget(self.preview_status)
btn_layout = QHBoxLayout()
apply_btn = QPushButton("Apply renames")
apply_btn.setMinimumHeight(36)
apply_btn.clicked.connect(self._apply_renames)
undo_btn = QPushButton("Undo last rename")
undo_btn.setMinimumHeight(36)
undo_btn.clicked.connect(self._undo_renames)
btn_layout.addWidget(apply_btn)
btn_layout.addWidget(undo_btn)
right_layout.addLayout(btn_layout)
split.addWidget(right)
split.setSizes([320, 680])
self._split = split
layout.addWidget(split, 1)
def _settings(self) -> QSettings:
return QSettings()
def _restore_layout(self) -> None:
settings = self._settings()
geometry = settings.value("window/geometry")
if geometry is not None:
self.restoreGeometry(geometry)
if self._split is not None:
split_state = settings.value("splitter/state")
if split_state is not None:
self._split.restoreState(split_state)
header = self.preview_table.horizontalHeader()
header_state = settings.value("preview_table/header")
if header_state is not None:
header.restoreState(header_state)
def _save_layout(self) -> None:
settings = self._settings()
settings.setValue("window/geometry", self.saveGeometry())
if self._split is not None:
settings.setValue("splitter/state", self._split.saveState())
settings.setValue(
"preview_table/header",
self.preview_table.horizontalHeader().saveState(),
)
def closeEvent(self, event) -> None:
self._save_layout()
super().closeEvent(event)
def _browse(self):
path = QFileDialog.getExistingDirectory(self, "Select folder")
if path:
self.dir_edit.setText(path)
def _on_types_preset_changed(self, preset: str):
if preset != "Custom":
self.types_edit.setText(FILE_TYPE_PRESETS.get(preset, ""))
self.types_edit.setReadOnly(True)
else:
self.types_edit.setReadOnly(False)
self._reload_files()
def _on_dir_changed(self):
path = self.dir_edit.text().strip()
if not path or not os.path.isdir(path):
self._base_dir = ""
self._file_names = []
else:
self._base_dir = path
self._reload_files()
return
self._refresh_preview()
def _reload_files(self):
if not self._base_dir:
return
extensions = parse_extension_filter(self.types_edit.text())
self._file_names = list_files(
self._base_dir,
recursive=self.recursive_cb.isChecked(),
extensions=extensions,
)
self._refresh_preview()
def _on_rule_changed(self):
"""Refresh preview only if the changed rule has 'Use this rule' checked (no preview from unchecked rules)."""
sender = self.sender()
if sender is None:
self._refresh_preview()
return
if sender in self._rule_widgets and sender.enabled_cb.isChecked():
self._refresh_preview()
# else: change was in an unchecked rule (e.g. mouse wheel on spinbox) — do not refresh
def _get_rules(self) -> list[Rule]:
"""All rules in fixed order; only enabled rules are applied."""
return [w.getRule() for w in self._rule_widgets]
def _ext_for(self, filename: str) -> str:
"""Return file extension with leading dot, or empty string if none."""
return file_extension(filename)
def _on_preview_selection_changed(self):
"""Show preview (new name) only in selected rows; others show original."""
if not self._preview_by_orig:
return
selected_rows = {idx.row() for idx in self.preview_table.selectionModel().selectedRows()}
for row in range(self.preview_table.rowCount()):
orig_item = self.preview_table.item(row, 0)
if not orig_item:
continue
orig = orig_item.text()
display_name = self._preview_by_orig.get(orig, orig) if row in selected_rows else orig
self.preview_table.setItem(row, 1, QTableWidgetItem(display_name))
def _schedule_refresh_preview(self):
if self._preview_refresh_scheduled:
return
self._preview_refresh_scheduled = True
QTimer.singleShot(0, self._run_scheduled_refresh)
def _run_scheduled_refresh(self):
self._preview_refresh_scheduled = False
try:
self._refresh_preview()
except Exception as exc:
self.preview_status.setText(f"Preview error: {exc}")
def _refresh_preview(self):
rules = self._get_rules()
if not self._file_names:
self._preview_by_orig = {}
self.preview_table.setSortingEnabled(False)
self.preview_table.setRowCount(0)
self.preview_status.setText("Add a folder to see files.")
for w in self._rule_widgets:
if hasattr(w, "set_file_names"):
w.set_file_names([])
return
for w in self._rule_widgets:
if hasattr(w, "set_file_names"):
w.set_file_names(self._file_names)
# Preserve sort and selection so checking a rule doesnt reorder or clear selection
header = self.preview_table.horizontalHeader()
sort_section = header.sortIndicatorSection()
sort_order = header.sortIndicatorOrder()
selected_orig = set()
for idx in self.preview_table.selectionModel().selectedRows():
item = self.preview_table.item(idx.row(), 0)
if item:
selected_orig.add(item.text())
self.preview_table.setSortingEnabled(False)
self.preview_table.setRowCount(len(self._file_names))
for row, orig in enumerate(self._file_names):
self.preview_table.setItem(row, 0, QTableWidgetItem(orig))
self.preview_table.setItem(row, 1, QTableWidgetItem(orig)) # show original until selected
self.preview_table.setItem(row, 2, QTableWidgetItem(self._ext_for(orig)))
self.preview_table.setSortingEnabled(True)
# Actually sort the table (setSortIndicator alone doesnt); default to File type so episode renumber doesnt skip
if sort_section < 0:
sort_section = 2
sort_order = Qt.SortOrder.AscendingOrder
self.preview_table.sortByColumn(sort_section, sort_order)
# Numbering uses the tables current (sorted) order, not load order
order = [self.preview_table.item(r, 0).text() for r in range(self.preview_table.rowCount())]
preview = compute_preview(order, rules)
self._preview_by_orig = {orig: new_name for orig, new_name in preview}
new_names = [p[1] for p in preview]
has_collision = len(new_names) != len(set(new_names))
# Restore selection by original name (multi-row)
sm = self.preview_table.selectionModel()
sm.clear()
for row in range(self.preview_table.rowCount()):
item = self.preview_table.item(row, 0)
if item and item.text() in selected_orig:
idx = self.preview_table.model().index(row, 0)
sm.select(idx, QItemSelectionModel.SelectionFlag.Select | QItemSelectionModel.SelectionFlag.Rows)
self._on_preview_selection_changed()
if has_collision:
self.preview_status.setText("Warning: some new names are duplicated. Fix rules to avoid overwriting.")
self.preview_status.setStyleSheet("color: #c00;")
else:
scope = "including subfolders" if self.recursive_cb.isChecked() else "top folder only"
self.preview_status.setText(f"{len(preview)} file(s) ({scope}). Ready to rename.")
self.preview_status.setStyleSheet("")
def _apply_renames(self):
if not self._base_dir or not self._file_names:
QMessageBox.warning(self, "No files", "Select a folder with files first.")
return
rules = self._get_rules()
# Use current table order (after user sort) so numbering/episode renumber follow visible order
if self.preview_table.rowCount() > 0:
order = [self.preview_table.item(r, 0).text() for r in range(self.preview_table.rowCount())]
else:
order = self._file_names
full_preview = compute_preview(order, rules)
# Only rename selected rows; if nothing selected, rename all that would change
selected_orig = {
self.preview_table.item(idx.row(), 0).text()
for idx in self.preview_table.selectionModel().selectedRows()
if self.preview_table.item(idx.row(), 0)
}
if selected_orig:
renames = [(a, b) for a, b in full_preview if a in selected_orig and a != b]
else:
renames = [(a, b) for a, b in full_preview if a != b]
if not renames:
QMessageBox.information(self, "Nothing to do", "No names would change with current rules.")
return
new_names = [b for _, b in renames]
if len(new_names) != len(set(new_names)):
QMessageBox.critical(
self,
"Collision",
"Some new names are duplicated. Change your rules to avoid overwriting files.",
)
return
ok = QMessageBox.question(
self,
"Confirm",
f"Rename {len(renames)} file(s) in\n{self._base_dir}?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if ok != QMessageBox.StandardButton.Yes:
return
results = perform_renames(self._base_dir, renames, dry_run=False)
errors = [r for r in results if r[2]]
if errors:
msg = "\n".join(f"{r[0]}: {r[2]}" for r in errors[:10])
if len(errors) > 10:
msg += f"\n... and {len(errors) - 10} more."
QMessageBox.warning(self, "Rename errors", msg)
else:
save_undo_log(self._base_dir, renames)
QMessageBox.information(self, "Done", f"Renamed {len(results)} file(s).")
self._reload_files()
def _undo_renames(self):
if not self._base_dir:
QMessageBox.warning(self, "No folder", "Select a folder first.")
return
renames = load_undo_log(self._base_dir)
if not renames:
QMessageBox.information(
self,
"No undo data",
f"No undo data for this folder.\n\nUndo is available after you run \"Apply renames\" here.",
)
return
ok = QMessageBox.question(
self,
"Undo last rename",
f"Revert {len(renames)} file(s) in\n{self._base_dir}\nback to their previous names?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if ok != QMessageBox.StandardButton.Yes:
return
results = perform_undo(self._base_dir)
errors = [r for r in results if r[2]]
if errors:
msg = "\n".join(f"{r[0]}: {r[2]}" for r in errors[:10])
if len(errors) > 10:
msg += f"\n... and {len(errors) - 10} more."
QMessageBox.warning(self, "Undo errors", msg)
else:
QMessageBox.information(self, "Undone", f"Reverted {len(results)} file(s).")
self._reload_files()