""" 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, ) from PyQt6.QtCore import Qt, QDir, QItemSelectionModel from PyQt6.QtGui import QFont, QColor from engine.pipeline import compute_preview, perform_renames from engine.rules import Rule from .rule_widgets import ( ReplaceRuleWidget, RegexRuleWidget, InsertRuleWidget, RemoveRuleWidget, CaseRuleWidget, NumberingRuleWidget, EpisodeRenumberRuleWidget, PrefixSuffixRuleWidget, ) class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("Bulk Renamer") 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._setup_ui() 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) # 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(), PrefixSuffixRuleWidget(), ] rule_titles = [ "1. Replace", "2. Regex", "3. Insert", "4. Remove", "5. Case", "6. Numbering", "7. Episode renumber", "8. Prefix / Suffix", ] 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 rule’s options change 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) apply_btn = QPushButton("Apply renames") apply_btn.setMinimumHeight(36) apply_btn.clicked.connect(self._apply_renames) right_layout.addWidget(apply_btn) split.addWidget(right) split.setSizes([320, 680]) layout.addWidget(split, 1) def _browse(self): path = QFileDialog.getExistingDirectory(self, "Select folder") if path: self.dir_edit.setText(path) 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._file_names = sorted( f for f in os.listdir(path) if os.path.isfile(os.path.join(path, f)) and not f.startswith(".") ) 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.""" if "." in filename and not filename.startswith("."): return "." + filename.rsplit(".", 1)[-1].lower() return "" 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 _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.") return # Preserve sort and selection so checking a rule doesn’t 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) header.setSortIndicator(sort_section, sort_order) # Numbering uses the table’s 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: self.preview_status.setText(f"{len(preview)} file(s). 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: QMessageBox.information(self, "Done", f"Renamed {len(results)} file(s).") self._on_dir_changed()