diff --git a/.gitignore b/.gitignore index 4fa767f..a10005e 100644 --- a/.gitignore +++ b/.gitignore @@ -219,5 +219,7 @@ __marimo__/ logs/ app.log -# Development/test scripts -run_refactored_ui.py +# Backup files +*.backup +*.bak +*~ diff --git a/README.md b/README.md index b9eedcb..ea54783 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,9 @@ A modern Python utility to merge multiple PowerPoint (.pptx) files into a single - **Modern package structure** for easy installation and development - **Command-line interface** for easy execution -## New Refactored GUI (PySide6) +## Modern GUI (PySide6) -The application now includes a modern, refactored GUI built with PySide6, featuring: +The application features a modern GUI built with PySide6, offering an intuitive two-column layout: ### Two-Column Layout - **Left Column (3:1 ratio)**: Main interaction area with smart state management @@ -40,19 +40,27 @@ The application now includes a modern, refactored GUI built with PySide6, featur - **Settings Persistence**: Remembers last save location between sessions - **Internationalization Ready**: All UI strings centralized for easy translation -### Using the Refactored GUI +### Using the GUI Programmatically + +The GUI can be embedded in your own applications. Usage example: ```python -from merge_powerpoint.gui_refactored import MainUI +from merge_powerpoint.gui import MainUI from merge_powerpoint.powerpoint_core import PowerPointMerger -from PySide6.QtWidgets import QApplication +from PySide6.QtWidgets import QApplication, QMainWindow import sys app = QApplication(sys.argv) merger = PowerPointMerger() -window = MainUI(merger=merger) -window.resize(1000, 600) -window.show() + +# MainUI is a QWidget, so embed it in a QMainWindow +main_window = QMainWindow() +ui = MainUI(merger=merger) +main_window.setCentralWidget(ui) +main_window.setWindowTitle("PowerPoint Presentation Merger") +main_window.resize(1000, 600) +main_window.show() + sys.exit(app.exec()) ``` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0c1d299..e1f206d 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -51,7 +51,7 @@ main.py └── merge_powerpoint package ├── app.py (AppController) │ └── powerpoint_core.py (PowerPointMerger) - ├── gui.py (MainWindow) + ├── gui.py (MainUI) │ ├── PySide6.QtWidgets │ └── powerpoint_core.py (PowerPointMerger) └── app_logger.py (setup_logging) @@ -147,24 +147,30 @@ run_with_logging.py #### `gui.py` - User Interface -- **Purpose**: PySide6-based graphical user interface +- **Purpose**: PySide6-based graphical user interface with modern two-column layout - **Framework**: PySide6 (Qt for Python) -- **Key Class**: `MainWindow` +- **Key Class**: `MainUI` (QWidget) +- **Architecture**: Signal-based with threaded operations - **Features**: - - File list management - - Add/Remove/Clear operations - - File reordering (Move Up/Down) - - Merge with progress tracking - - Input validation + - Two-column layout with 3:1 ratio + - Drag-and-drop file support + - Smart state management (drop zone ↔ file list) + - Background merge operations (QThread) + - Real-time progress tracking + - Settings persistence (QSettings) + - Input validation and duplicate prevention - **Components**: - - QListWidget for file display - - QPushButtons for actions - - QProgressBar for merge progress - - QFileDialog for file selection - - File dialog integration - - Move Up/Down file reordering - - Keyboard shortcuts (Enter key support) - - Visual feedback and styling + - `FileListModel`: Manages file list data + - `FileItemDelegate`: Custom rendering for file items + - `DropZoneWidget`: Drag-and-drop zone with visual feedback + - `MergeWorker`: QThread for asynchronous merging + - `MainUI`: Main widget with signal/slot architecture +- **Key Signals**: + - `files_added`: When files are added + - `file_removed`: When a file is removed + - `order_changed`: When file order changes + - `clear_requested`: When clear all is requested + - `merge_requested`: When merge is initiated ### 4. Business Logic Layer @@ -210,7 +216,9 @@ run_with_logging.py ├── Launch via CLI (merge-powerpoint) or script (python main.py) ├── Initialize logging ├── Create QApplication - └── Show MainWindow + ├── Create MainUI widget + ├── Embed MainUI in QMainWindow + └── Show window 2. File Management ├── User clicks "Add Files" diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index 5ecefd7..994e113 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -130,11 +130,11 @@ from gui import MainWindow ```python # New way (recommended) from merge_powerpoint.powerpoint_core import PowerPointMerger -from merge_powerpoint.gui import MainWindow +from merge_powerpoint.gui import MainUI # Old way (still works via compatibility shims) from powerpoint_core import PowerPointMerger -from gui import MainWindow +from gui import MainUI ``` ## For Developers diff --git a/main.py b/main.py index 4d9a15c..9c30844 100644 --- a/main.py +++ b/main.py @@ -12,11 +12,10 @@ if str(src_path) not in sys.path: sys.path.insert(0, str(src_path)) -from PySide6.QtWidgets import QApplication # noqa: E402 +from PySide6.QtWidgets import QApplication, QMainWindow # noqa: E402 -from merge_powerpoint.app import AppController # noqa: E402 from merge_powerpoint.app_logger import setup_logging # noqa: E402 -from merge_powerpoint.gui import MainWindow # noqa: E402 +from merge_powerpoint.gui import MainUI # noqa: E402 def main(): @@ -27,9 +26,16 @@ def main(): """ setup_logging() app = QApplication(sys.argv) - controller = AppController() - window = MainWindow() - window.show() + app.setApplicationName("PowerPoint Merger") + app.setOrganizationName("MergePowerPoint") + + # MainUI is a QWidget, so embed it in a QMainWindow + main_window = QMainWindow() + ui = MainUI() + main_window.setCentralWidget(ui) + main_window.setWindowTitle("PowerPoint Presentation Merger") + main_window.resize(1000, 600) + main_window.show() return app.exec() diff --git a/new_gui/main_gui.py b/new_gui/main_gui.py index 6b65284..c27faf3 100644 --- a/new_gui/main_gui.py +++ b/new_gui/main_gui.py @@ -5,9 +5,9 @@ application window from the `gui` module and runs it. The key functionality is modifying `sys.path` to allow imports from the parent directory. """ -import sys -import os import logging +import os +import sys # --- FIX FOR ModuleNotFoundError --- # Get the absolute path of the directory containing this script (new_gui). @@ -19,9 +19,11 @@ # --------------------------------- # Now that the project root is on the path, these imports will succeed. -from gui import MainApplication from logger import setup_logging +from gui import MainApplication + + def main(): """ Initializes logging and runs the GUI application for testing. @@ -32,7 +34,7 @@ def main(): app = MainApplication() app.mainloop() logging.info("GUI application closed normally.") - except Exception as e: + except Exception: logging.critical("The GUI application encountered a fatal error.", exc_info=True) # In a real-world scenario, you might show an error dialog here. diff --git a/src/merge_powerpoint/__main__.py b/src/merge_powerpoint/__main__.py index 93166ce..106bd0b 100644 --- a/src/merge_powerpoint/__main__.py +++ b/src/merge_powerpoint/__main__.py @@ -6,10 +6,10 @@ import sys -from PySide6.QtWidgets import QApplication +from PySide6.QtWidgets import QApplication, QMainWindow from merge_powerpoint.app_logger import setup_logging -from merge_powerpoint.gui import MainWindow +from merge_powerpoint.gui import MainUI def main(): @@ -23,8 +23,16 @@ def main(): """ setup_logging() app = QApplication(sys.argv) - window = MainWindow() - window.show() + app.setApplicationName("PowerPoint Merger") + app.setOrganizationName("MergePowerPoint") + + # MainUI is a QWidget, so embed it in a QMainWindow + main_window = QMainWindow() + ui = MainUI() + main_window.setCentralWidget(ui) + main_window.setWindowTitle("PowerPoint Presentation Merger") + main_window.resize(1000, 600) + main_window.show() return app.exec() diff --git a/src/merge_powerpoint/gui.py b/src/merge_powerpoint/gui.py index 02b67af..e9cafd2 100644 --- a/src/merge_powerpoint/gui.py +++ b/src/merge_powerpoint/gui.py @@ -1,115 +1,307 @@ -"""Main graphical user interface for the PowerPoint Merger application. +"""Refactored modern GUI for PowerPoint Merger using PySide6. -This module defines the GUI components using PySide6 framework. It provides -an intuitive interface for managing and merging PowerPoint presentations with -features including file selection, reordering, and progress tracking. +This module implements a modern, two-column interface with drag-and-drop +support, custom item delegates, and signal-based architecture following +best practices for PySide6 applications. """ -import sys -from pathlib import Path - -from PySide6.QtGui import QIcon +import logging +import os +from typing import List, Optional + +from PySide6.QtCore import ( + QModelIndex, + QSettings, + Qt, + QThread, + Signal, + Slot, +) +from PySide6.QtGui import ( + QDragEnterEvent, + QDropEvent, + QIcon, + QPainter, + QStandardItem, + QStandardItemModel, +) from PySide6.QtWidgets import ( QApplication, QFileDialog, + QFrame, + QGroupBox, QHBoxLayout, - QListWidget, - QListWidgetItem, - QMainWindow, + QLabel, + QLineEdit, + QListView, QMessageBox, QProgressBar, QPushButton, + QStyledItemDelegate, QVBoxLayout, QWidget, ) -from merge_powerpoint.app_logger import setup_logging -from merge_powerpoint.powerpoint_core import PowerPointMerger +# Import compiled resources +try: + from merge_powerpoint import icons_rc # noqa: F401 +except ImportError: + icons_rc = None # Icons won't be available if resources not compiled + +from merge_powerpoint.powerpoint_core import PowerPointError, PowerPointMerger + +logger = logging.getLogger(__name__) + + +# UI strings for internationalization +UI_STRINGS = { + "window_title": "PowerPoint Presentation Merger", + "drop_zone_text": "Drag and drop PowerPoint files here", + "browse_button": "Browse for Files...", + "clear_list_button": "Clear List", + "output_group_title": "Output File", + "output_filename_default": "merged-presentation.pptx", + "save_to_button": "Save to...", + "merge_button": "Merge Presentations", + "remove_tooltip": "Remove this file", + "not_enough_files_title": "Not Enough Files", + "not_enough_files_message": "Please add at least two PowerPoint files to merge.", + "invalid_file_title": "Invalid File", + "invalid_file_message": "Only .pptx files are accepted.", + "merge_success_title": "Success", + "merge_success_message": "Presentation saved to {path}", + "merge_error_title": "Merge Error", + "file_not_readable_title": "File Error", + "file_not_readable_message": "Cannot read file: {path}", +} + + +class FileListModel(QStandardItemModel): + """Model for managing the list of PowerPoint files. + + This model provides a data structure for file paths with support for + drag-and-drop reordering and duplicate prevention. + """ + + def __init__(self, parent=None): + """Initialize the file list model. + + Args: + parent: Optional parent QObject. + """ + super().__init__(parent) + self.file_paths: List[str] = [] -# Set up logging -logger = setup_logging() + def add_files(self, paths: List[str]) -> List[str]: + """Add files to the model, preventing duplicates. + Args: + paths: List of file paths to add. -class MainWindow(QMainWindow): - """Main window for the PowerPoint Merger application. + Returns: + List of paths that were rejected (duplicates or invalid). + """ + rejected = [] + for path in paths: + if path in self.file_paths: + rejected.append(path) + logger.debug(f"Rejected duplicate file: {path}") + elif not path.lower().endswith('.pptx'): + rejected.append(path) + logger.debug(f"Rejected non-pptx file: {path}") + else: + self.file_paths.append(path) + item = QStandardItem(path) + item.setData(path, Qt.UserRole) + self.appendRow(item) + logger.info(f"Added file: {path}") + return rejected + + def remove_file(self, path: str) -> bool: + """Remove a file from the model. + + Args: + path: File path to remove. - Provides a graphical interface for users to: - - Add and remove PowerPoint files - - Reorder files for merging - - Merge files with progress tracking - - View and manage the file list + Returns: + True if file was removed, False if not found. + """ + if path in self.file_paths: + idx = self.file_paths.index(path) + self.removeRow(idx) + self.file_paths.remove(path) + logger.info(f"Removed file: {path}") + return True + return False + + def clear_all(self): + """Remove all files from the model.""" + self.clear() + self.file_paths = [] + logger.info("Cleared all files") + + def get_file_paths(self) -> List[str]: + """Get the current ordered list of file paths. + + Returns: + List of file paths in current order. + """ + return self.file_paths.copy() + + def reorder_files(self, new_order: List[str]): + """Update the file order based on a new list. + + Args: + new_order: New ordered list of file paths. + """ + self.file_paths = new_order.copy() + self.clear() + for path in new_order: + item = QStandardItem(path) + item.setData(path, Qt.UserRole) + self.appendRow(item) + logger.info(f"Reordered files: {len(new_order)} items") + + def supportedDragActions(self): + """Support move operations for drag and drop.""" + return Qt.MoveAction + + def supportedDropActions(self): + """Support move operations for drag and drop.""" + return Qt.MoveAction + + +class FileItemDelegate(QStyledItemDelegate): + """Custom delegate for rendering file items as cards. + + Each file item is rendered with an icon, filename, and remove button. """ + remove_clicked = Signal(str) # Emits the file path + def __init__(self, parent=None): - """Initialize the main window. + """Initialize the delegate. Args: - parent: Optional parent widget (default: None). + parent: Optional parent QObject. """ super().__init__(parent) - self.setWindowTitle("PowerPoint Presentation Merger") - self.setGeometry(100, 100, 800, 600) - - # Set the application icon - icon_path = Path("resources/MergePowerPoint.ico") - if icon_path.exists(): - self.setWindowIcon(QIcon(str(icon_path))) + self._icon = QIcon(":/icons/powerpoint.svg") - self.merger = PowerPointMerger() - self.central_widget = QWidget() - self.setCentralWidget(self.central_widget) - self.layout = QVBoxLayout(self.central_widget) + def paint(self, painter: QPainter, option, index: QModelIndex): + """Paint the file item as a card. - self._setup_ui() - - def _setup_ui(self): - """Set up the UI components.""" - # File list display - self.file_list_widget = QListWidget() - self.file_list_widget.setSelectionMode(QListWidget.ExtendedSelection) - self.layout.addWidget(self.file_list_widget) + Args: + painter: QPainter to use for drawing. + option: Style options for the item. + index: Model index of the item. + """ + super().paint(painter, option, index) + # The base implementation will handle the text + # In a full implementation, we would custom-draw the entire card here - # Button layout - button_layout = QHBoxLayout() + def sizeHint(self, option, index: QModelIndex): + """Return the size hint for the item. - self.add_button = QPushButton("&Add Files") - self.add_button.clicked.connect(self.add_files) - button_layout.addWidget(self.add_button) + Args: + option: Style options for the item. + index: Model index of the item. - self.remove_button = QPushButton("&Remove Selected") - self.remove_button.clicked.connect(self.remove_selected_files) - button_layout.addWidget(self.remove_button) + Returns: + QSize for the item. + """ + size = super().sizeHint(option, index) + size.setHeight(max(size.height(), 48)) # Minimum height for card + return size - self.clear_button = QPushButton("&Clear All") - self.clear_button.clicked.connect(self.clear_all_files) - button_layout.addWidget(self.clear_button) - # Spacer to push reordering buttons to the right - button_layout.addStretch() +class DropZoneWidget(QFrame): + """Widget displayed when no files are in the list. - self.move_up_button = QPushButton("Move &Up") - self.move_up_button.clicked.connect(self.move_file_up) - button_layout.addWidget(self.move_up_button) + Shows a centered icon and text encouraging users to drag and drop files. + """ - self.move_down_button = QPushButton("Move &Down") - self.move_down_button.clicked.connect(self.move_file_down) - button_layout.addWidget(self.move_down_button) + files_dropped = Signal(list) # Emits list of file paths - self.layout.addLayout(button_layout) + def __init__(self, parent=None): + """Initialize the drop zone widget. - # Merge button and progress bar - self.merge_button = QPushButton("&Merge Files") - self.merge_button.clicked.connect(self.merge_files) - self.layout.addWidget(self.merge_button) + Args: + parent: Optional parent widget. + """ + super().__init__(parent) + self.setAcceptDrops(True) + self.setFrameShape(QFrame.StyledPanel) + self.setStyleSheet(""" + DropZoneWidget { + background-color: #f5f5f5; + border: 2px dashed #cccccc; + border-radius: 8px; + } + DropZoneWidget:hover { + border-color: #999999; + background-color: #eeeeee; + } + """) + + layout = QVBoxLayout(self) + layout.setAlignment(Qt.AlignCenter) + + # Plus icon + icon_label = QLabel() + icon_label.setPixmap( + QIcon(":/icons/plus.svg").pixmap(64, 64) + ) + icon_label.setAlignment(Qt.AlignCenter) + layout.addWidget(icon_label) + + # Text + text_label = QLabel(UI_STRINGS["drop_zone_text"]) + text_label.setAlignment(Qt.AlignCenter) + font = text_label.font() + font.setPointSize(14) + text_label.setFont(font) + text_label.setStyleSheet("color: #666666;") + layout.addWidget(text_label) + + # Browse button + browse_button = QPushButton(UI_STRINGS["browse_button"]) + browse_button.clicked.connect(self._browse_files) + browse_button.setMinimumWidth(150) + layout.addWidget(browse_button, alignment=Qt.AlignCenter) + + def dragEnterEvent(self, event: QDragEnterEvent): + """Handle drag enter events. - self.progress_bar = QProgressBar() - self.progress_bar.setVisible(False) - self.layout.addWidget(self.progress_bar) + Args: + event: Drag enter event. + """ + if event.mimeData().hasUrls(): + event.acceptProposedAction() + else: + event.ignore() - self.update_button_states() + def dropEvent(self, event: QDropEvent): + """Handle drop events. - def add_files(self): - """Open a dialog to add PowerPoint files.""" + Args: + event: Drop event containing file URLs. + """ + if event.mimeData().hasUrls(): + file_paths = [] + for url in event.mimeData().urls(): + if url.isLocalFile(): + path = url.toLocalFile() + if path.lower().endswith('.pptx'): + file_paths.append(path) + if file_paths: + self.files_dropped.emit(file_paths) + event.acceptProposedAction() + else: + event.ignore() + + def _browse_files(self): + """Open file dialog to browse for PowerPoint files.""" files, _ = QFileDialog.getOpenFileNames( self, "Select PowerPoint Files", @@ -117,119 +309,420 @@ def add_files(self): "PowerPoint Presentations (*.pptx)", ) if files: - self.merger.add_files(files) - self.update_file_list() - logger.info("Added files: %s", files) - - def remove_selected_files(self): - """Remove the selected files from the list.""" - selected_items = self.file_list_widget.selectedItems() - if not selected_items: - return + self.files_dropped.emit(files) - files_to_remove = [item.text() for item in selected_items] - self.merger.remove_files(files_to_remove) - self.update_file_list() - logger.info("Removed files: %s", files_to_remove) - - def clear_all_files(self): - """Clear all files from the list.""" - self.merger.clear_files() - self.update_file_list() - logger.info("Cleared all files.") - - def update_file_list(self): - """Update the file list widget with the current list of files.""" - self.file_list_widget.clear() - for file in self.merger.get_files(): - self.file_list_widget.addItem(QListWidgetItem(file)) - self.update_button_states() - - def merge_files(self): - """Initiate the file merging process.""" - if len(self.merger.get_files()) < 2: - QMessageBox.warning(self, "Not enough files", "Please add at least two files to merge.") - return - output_path, _ = QFileDialog.getSaveFileName( - self, "Save Merged File", "", "PowerPoint Presentation (*.pptx)" - ) - if output_path: - self.progress_bar.setVisible(True) - self.progress_bar.setValue(0) - try: - self.merger.merge(output_path, self.update_progress) - QMessageBox.information( - self, "Success", f"Successfully merged files to {output_path}" - ) - logger.info("Successfully merged files to %s", output_path) - except Exception as e: - QMessageBox.critical(self, "Error", f"An error occurred during merging: {e}") - logger.error("Merging failed: %s", e, exc_info=True) - finally: - self.progress_bar.setVisible(False) - - def move_file_up(self): - """Move the selected file up in the list.""" - selected_items = self.file_list_widget.selectedItems() - if not selected_items or len(selected_items) > 1: - return # Only move one item at a time - - current_index = self.file_list_widget.row(selected_items[0]) - if self.merger.move_file_up(current_index): - self.update_file_list() - self.file_list_widget.setCurrentRow(current_index - 1) - logger.info("Moved file up: %s", selected_items[0].text()) - - def move_file_down(self): - """Move the selected file down in the list.""" - selected_items = self.file_list_widget.selectedItems() - if not selected_items or len(selected_items) > 1: - return +class MergeWorker(QThread): + """Worker thread for performing merge operations. + + Signals: + progress: Emitted with (current, total) during merge. + finished: Emitted with (success, output_path, error_message). + """ + + progress = Signal(int, int) + finished = Signal(bool, str, str) + + def __init__(self, file_paths: List[str], output_path: str, merger: PowerPointMerger): + """Initialize the merge worker. - current_index = self.file_list_widget.row(selected_items[0]) - if self.merger.move_file_down(current_index): - self.update_file_list() - self.file_list_widget.setCurrentRow(current_index + 1) - logger.info("Moved file down: %s", selected_items[0].text()) + Args: + file_paths: List of file paths to merge. + output_path: Output file path. + merger: PowerPointMerger instance to use. + """ + super().__init__() + self.file_paths = file_paths + self.output_path = output_path + self.merger = merger + + def run(self): + """Execute the merge operation in the worker thread.""" + try: + logger.info(f"Starting merge of {len(self.file_paths)} files") + + def progress_callback(current, total): + self.progress.emit(current, total) + + # Clear and add files to merger + self.merger.clear_files() + self.merger.add_files(self.file_paths) + + # Perform merge + self.merger.merge(self.output_path, progress_callback) + + self.finished.emit(True, self.output_path, "") + logger.info(f"Merge completed successfully: {self.output_path}") + except PowerPointError as e: + logger.error(f"Merge failed: {e}", exc_info=True) + self.finished.emit(False, "", str(e)) + except Exception as e: + logger.error(f"Unexpected error during merge: {e}", exc_info=True) + self.finished.emit(False, "", str(e)) + + +class MainUI(QWidget): + """Main user interface widget for PowerPoint merger. + + This widget implements a two-column layout with file management on the left + and configuration/actions on the right. + + Signals: + files_added: Emitted when files are added (list[str] of paths). + file_removed: Emitted when a file is removed (str path). + order_changed: Emitted when file order changes (list[str] of paths). + clear_requested: Emitted when clear all is requested. + merge_requested: Emitted when merge is requested (str output path). + """ + + files_added = Signal(list) + file_removed = Signal(str) + order_changed = Signal(list) + clear_requested = Signal() + merge_requested = Signal(str) - def update_progress(self, value, total): - """Update the progress bar. + def __init__(self, merger: Optional[PowerPointMerger] = None, parent=None): + """Initialize the main UI. Args: - value: Current progress value. - total: Total number of items to process. + merger: Optional PowerPointMerger instance (injected dependency). + parent: Optional parent widget. """ - progress_percentage = int((value / total) * 100) - self.progress_bar.setValue(progress_percentage) - QApplication.processEvents() + super().__init__(parent) + self.merger = merger or PowerPointMerger() + self.file_model = FileListModel(self) + self.settings = QSettings("MergePowerPoint", "Merger") + self.merge_worker: Optional[MergeWorker] = None - def update_button_states(self): - """Enable or disable buttons based on the application state.""" - has_files = len(self.merger.get_files()) > 0 - has_selection = len(self.file_list_widget.selectedItems()) > 0 - can_merge = len(self.merger.get_files()) >= 2 + self._setup_ui() + self._connect_signals() + self._restore_settings() - self.remove_button.setEnabled(has_files and has_selection) + def _setup_ui(self): + """Set up the user interface components.""" + main_layout = QHBoxLayout(self) + main_layout.setSpacing(16) + main_layout.setContentsMargins(16, 16, 16, 16) + + # Left column: File list (3 parts in stretch) + left_widget = QWidget() + left_layout = QVBoxLayout(left_widget) + left_layout.setContentsMargins(0, 0, 0, 0) + + # Drop zone (shown when empty) + self.drop_zone = DropZoneWidget() + self.drop_zone.files_dropped.connect(self._on_files_dropped) + left_layout.addWidget(self.drop_zone) + + # File list view (shown when not empty) + self.file_list_view = QListView() + self.file_list_view.setModel(self.file_model) + self.file_list_view.setItemDelegate(FileItemDelegate()) + self.file_list_view.setDragEnabled(True) + self.file_list_view.setAcceptDrops(True) + self.file_list_view.setDropIndicatorShown(True) + self.file_list_view.setDragDropMode(QListView.InternalMove) + self.file_list_view.setSelectionMode(QListView.SingleSelection) + self.file_list_view.setVisible(False) + left_layout.addWidget(self.file_list_view) + + main_layout.addWidget(left_widget, 3) + + # Right column: Configuration and actions (1 part in stretch) + right_widget = QWidget() + right_layout = QVBoxLayout(right_widget) + right_layout.setContentsMargins(0, 0, 0, 0) + + # Clear list button + self.clear_button = QPushButton(UI_STRINGS["clear_list_button"]) + self.clear_button.setIcon(QIcon(":/icons/trash.svg")) + self.clear_button.clicked.connect(self._on_clear_clicked) + self.clear_button.setEnabled(False) + right_layout.addWidget(self.clear_button) + + right_layout.addSpacing(16) + + # Output file configuration + output_group = QGroupBox(UI_STRINGS["output_group_title"]) + output_layout = QVBoxLayout(output_group) + + self.output_filename_edit = QLineEdit() + self.output_filename_edit.setText(UI_STRINGS["output_filename_default"]) + self.output_filename_edit.setPlaceholderText("merged-presentation.pptx") + output_layout.addWidget(self.output_filename_edit) + + self.save_to_button = QPushButton(UI_STRINGS["save_to_button"]) + self.save_to_button.setIcon(QIcon(":/icons/folder.svg")) + self.save_to_button.clicked.connect(self._on_save_to_clicked) + output_layout.addWidget(self.save_to_button) + + right_layout.addWidget(output_group) + + right_layout.addStretch() + + # Progress bar + self.progress_bar = QProgressBar() + self.progress_bar.setVisible(False) + right_layout.addWidget(self.progress_bar) + + # Merge button + self.merge_button = QPushButton(UI_STRINGS["merge_button"]) + self.merge_button.setMinimumHeight(48) + self.merge_button.clicked.connect(self._on_merge_clicked) + self.merge_button.setEnabled(False) + font = self.merge_button.font() + font.setPointSize(12) + font.setBold(True) + self.merge_button.setFont(font) + right_layout.addWidget(self.merge_button) + + main_layout.addWidget(right_widget, 1) + + # Set initial state + self._update_ui_state() + + def _connect_signals(self): + """Connect internal signals to slots.""" + self.file_model.rowsInserted.connect(self._on_model_changed) + self.file_model.rowsRemoved.connect(self._on_model_changed) + self.file_model.modelReset.connect(self._on_model_changed) + + def _restore_settings(self): + """Restore saved settings from previous session.""" + last_save_dir = self.settings.value("last_save_dir", "") + if last_save_dir and os.path.isdir(last_save_dir): + self.last_save_dir = last_save_dir + else: + self.last_save_dir = os.path.expanduser("~") + + def _save_settings(self): + """Save current settings for next session.""" + if hasattr(self, 'last_save_dir'): + self.settings.setValue("last_save_dir", self.last_save_dir) + + def _update_ui_state(self): + """Update UI state based on file list.""" + has_files = len(self.file_model.file_paths) > 0 + can_merge = len(self.file_model.file_paths) >= 2 + + # Toggle drop zone vs file list + self.drop_zone.setVisible(not has_files) + self.file_list_view.setVisible(has_files) + + # Update button states self.clear_button.setEnabled(has_files) self.merge_button.setEnabled(can_merge) - # Logic for move up/down buttons - can_move_up = False - can_move_down = False - if has_selection and len(self.file_list_widget.selectedItems()) == 1: - selected_index = self.file_list_widget.currentRow() - if selected_index > 0: - can_move_up = True - if selected_index < self.file_list_widget.count() - 1: - can_move_down = True + def _on_files_dropped(self, file_paths: List[str]): + """Handle files dropped onto the drop zone. + + Args: + file_paths: List of file paths that were dropped. + """ + # Validate files exist and are readable + valid_files = [] + for path in file_paths: + if not os.path.exists(path): + logger.warning(f"File does not exist: {path}") + continue + if not os.path.isfile(path): + logger.warning(f"Not a file: {path}") + continue + # Skip the read check for now to avoid blocking + valid_files.append(path) + + if valid_files: + rejected = self.file_model.add_files(valid_files) + if rejected: + # Show brief status about rejected files + logger.info(f"Rejected {len(rejected)} files (duplicates or invalid)") + self.files_added.emit(valid_files) + + @Slot() + def _on_clear_clicked(self): + """Handle clear button click.""" + self.file_model.clear_all() + self.clear_requested.emit() + + @Slot() + def _on_save_to_clicked(self): + """Handle save to button click.""" + initial_dir = self.last_save_dir + file_path, _ = QFileDialog.getSaveFileName( + self, + "Save Merged Presentation", + os.path.join(initial_dir, self.output_filename_edit.text()), + "PowerPoint Presentations (*.pptx)", + ) + if file_path: + # Update the filename field and save directory + self.output_filename_edit.setText(os.path.basename(file_path)) + self.last_save_dir = os.path.dirname(file_path) + self._save_settings() + + @Slot() + def _on_merge_clicked(self): + """Handle merge button click.""" + file_paths = self.file_model.get_file_paths() + if len(file_paths) < 2: + QMessageBox.warning( + self, + UI_STRINGS["not_enough_files_title"], + UI_STRINGS["not_enough_files_message"] + ) + return + + # Get output path + filename = self.output_filename_edit.text().strip() + if not filename: + filename = UI_STRINGS["output_filename_default"] + + # Ensure .pptx extension + if not filename.lower().endswith('.pptx'): + filename += '.pptx' + + output_path = os.path.join(self.last_save_dir, filename) + + # Confirm overwrite if file exists + if os.path.exists(output_path): + reply = QMessageBox.question( + self, + "Confirm Overwrite", + f"File already exists:\n{output_path}\n\nOverwrite?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No + ) + if reply != QMessageBox.Yes: + return + + # Start merge in worker thread + self._start_merge(file_paths, output_path) + + def _start_merge(self, file_paths: List[str], output_path: str): + """Start the merge operation in a worker thread. + + Args: + file_paths: List of file paths to merge. + output_path: Path where merged file will be saved. + """ + # Disable UI during merge + self._set_ui_enabled(False) + self.progress_bar.setVisible(True) + self.progress_bar.setValue(0) + + # Create and start worker + self.merge_worker = MergeWorker(file_paths, output_path, self.merger) + self.merge_worker.progress.connect(self._on_merge_progress) + self.merge_worker.finished.connect(self._on_merge_finished) + self.merge_worker.start() + + self.merge_requested.emit(output_path) - self.move_up_button.setEnabled(can_move_up) - self.move_down_button.setEnabled(can_move_down) + @Slot(int, int) + def _on_merge_progress(self, current: int, total: int): + """Handle merge progress updates. + + Args: + current: Current progress value. + total: Total number of items. + """ + if total > 0: + percentage = int((current / total) * 100) + self.progress_bar.setValue(percentage) + + @Slot(bool, str, str) + def _on_merge_finished(self, success: bool, output_path: str, error_message: str): + """Handle merge completion. + + Args: + success: Whether merge was successful. + output_path: Path to output file. + error_message: Error message if unsuccessful. + """ + # Re-enable UI + self._set_ui_enabled(True) + self.progress_bar.setVisible(False) + + if success: + # Show success message with link to open folder + msg = QMessageBox(self) + msg.setIcon(QMessageBox.Information) + msg.setWindowTitle(UI_STRINGS["merge_success_title"]) + msg.setText(UI_STRINGS["merge_success_message"].format(path=output_path)) + msg.setStandardButtons(QMessageBox.Ok) + + # Add button to open folder + open_folder_button = msg.addButton("Open Folder", QMessageBox.ActionRole) + msg.exec() + + if msg.clickedButton() == open_folder_button: + self._open_folder(output_path) + else: + QMessageBox.critical( + self, + UI_STRINGS["merge_error_title"], + f"Merge failed:\n{error_message}" + ) + + self.merge_worker = None + + def _open_folder(self, file_path: str): + """Open the folder containing the specified file. + + Args: + file_path: Path to the file. + """ + import platform + import subprocess + + folder = os.path.dirname(os.path.abspath(file_path)) + + try: + if platform.system() == "Windows": + subprocess.run(["explorer", "/select,", file_path]) + elif platform.system() == "Darwin": # macOS + subprocess.run(["open", "-R", file_path]) + else: # Linux + subprocess.run(["xdg-open", folder]) + except Exception as e: + logger.warning(f"Failed to open folder: {e}") + + def _set_ui_enabled(self, enabled: bool): + """Enable or disable UI controls. + + Args: + enabled: Whether to enable controls. + """ + self.clear_button.setEnabled(enabled and len(self.file_model.file_paths) > 0) + self.merge_button.setEnabled(enabled and len(self.file_model.file_paths) >= 2) + self.save_to_button.setEnabled(enabled) + self.output_filename_edit.setEnabled(enabled) + self.file_list_view.setEnabled(enabled) + self.drop_zone.setEnabled(enabled) + + @Slot() + def _on_model_changed(self): + """Handle changes to the file model.""" + self._update_ui_state() + if self.file_model.rowCount() > 0: + self.order_changed.emit(self.file_model.get_file_paths()) if __name__ == "__main__": + import sys + + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + app = QApplication(sys.argv) - window = MainWindow() + app.setApplicationName("PowerPoint Merger") + app.setOrganizationName("MergePowerPoint") + + window = MainUI() + window.setWindowTitle(UI_STRINGS["window_title"]) + window.resize(1000, 600) window.show() + sys.exit(app.exec()) diff --git a/src/merge_powerpoint/icons_rc.py b/src/merge_powerpoint/icons_rc.py index 19d20a2..b9c3d99 100644 --- a/src/merge_powerpoint/icons_rc.py +++ b/src/merge_powerpoint/icons_rc.py @@ -159,15 +159,15 @@ \x00\x00\x00\x00\x00\x02\x00\x00\x00\x05\x00\x00\x00\x03\ \x00\x00\x00\x00\x00\x00\x00\x00\ \x00\x00\x00|\x00\x00\x00\x00\x00\x01\x00\x00\x05p\ -\x00\x00\x01\x99\xd3\xe9\xa1\xfa\ +\x00\x00\x01\x99\xd4\xa5\xca;\ \x00\x00\x00\x10\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ -\x00\x00\x01\x99\xd3\xe9\xa1\xfa\ +\x00\x00\x01\x99\xd4\xa5\xca;\ \x00\x00\x00d\x00\x00\x00\x00\x00\x01\x00\x00\x04O\ -\x00\x00\x01\x99\xd3\xe9\xa1\xfa\ +\x00\x00\x01\x99\xd4\xa5\xca;\ \x00\x00\x00L\x00\x00\x00\x00\x00\x01\x00\x00\x02\xf9\ -\x00\x00\x01\x99\xd3\xe9\xa1\xfa\ +\x00\x00\x01\x99\xd4\xa5\xca;\ \x00\x00\x002\x00\x00\x00\x00\x00\x01\x00\x00\x01\xd6\ -\x00\x00\x01\x99\xd3\xe9\xa1\xfa\ +\x00\x00\x01\x99\xd4\xa5\xca;\ " def qInitResources(): diff --git a/tests/conftest.py b/tests/conftest.py index 2729342..d48a9d8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,7 +17,7 @@ import pytest from merge_powerpoint.app import AppController -from merge_powerpoint.gui import MainWindow +from merge_powerpoint.gui import MainUI # Register the pytest-qt plugin. pytest_plugins = "pytestqt" @@ -35,16 +35,16 @@ def app_controller(qapp): @pytest.fixture def main_window(qtbot): """ - Creates and returns an instance of the MainWindow. + Creates and returns an instance of the MainUI. This fixture is used for testing the GUI in isolation. """ - # MainWindow no longer takes a controller argument - it creates its own PowerPointMerger - window = MainWindow() + # MainUI is a QWidget that creates its own PowerPointMerger + ui = MainUI() # Register the widget with qtbot for interaction and garbage collection - qtbot.addWidget(window) + qtbot.addWidget(ui) - # Show the window before the test runs - window.show() + # Show the widget before the test runs + ui.show() - return window + return ui diff --git a/tests/test_gui.py b/tests/test_gui.py index 45c7d1c..cfd81c1 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -1,87 +1,130 @@ +"""Tests for the refactored MainUI widget. + +This module tests the modern two-column interface including basic +functionality, state management, and user interactions. +""" from PySide6.QtCore import Qt -def test_main_window_initial_state(main_window): - """ - Test the initial state of the MainWindow. - """ - # Check the correct window title - assert main_window.windowTitle() == "PowerPoint Presentation Merger" - # Check that file list is empty - assert main_window.file_list_widget.count() == 0 +def test_main_ui_initial_state(main_window): + """Test the initial state of the MainUI widget.""" + # Check that file model is empty + assert len(main_window.file_model.file_paths) == 0 + assert main_window.file_model.rowCount() == 0 # Merge button should be disabled when there are no files assert not main_window.merge_button.isEnabled() + # Clear button should be disabled + assert not main_window.clear_button.isEnabled() # Progress bar should be hidden initially assert not main_window.progress_bar.isVisible() + # Drop zone should be visible, file list hidden + assert main_window.drop_zone.isVisible() + assert not main_window.file_list_view.isVisible() + + +def test_add_files(main_window): + """Test adding files to the file model.""" + files = ["/path/to/file1.pptx", "/path/to/file2.pptx"] + rejected = main_window.file_model.add_files(files) + + assert rejected == [] + assert main_window.file_model.rowCount() == 2 + assert main_window.file_model.file_paths == files + # Merge button should be enabled with 2+ files + assert main_window.merge_button.isEnabled() + # Clear button should be enabled + assert main_window.clear_button.isEnabled() + # File list should be visible, drop zone hidden + assert main_window.file_list_view.isVisible() + assert not main_window.drop_zone.isVisible() + + +def test_clear_files(main_window): + """Test clearing all files.""" + # Add some files first + files = ["/path/to/file1.pptx", "/path/to/file2.pptx"] + main_window.file_model.add_files(files) + + # Clear them + main_window.file_model.clear_all() + + assert main_window.file_model.rowCount() == 0 + assert len(main_window.file_model.file_paths) == 0 + # UI should reset to initial state + assert not main_window.merge_button.isEnabled() + assert not main_window.clear_button.isEnabled() + assert main_window.drop_zone.isVisible() + assert not main_window.file_list_view.isVisible() + +def test_merge_progress_update(main_window): + """Test that the progress bar updates correctly.""" + # Make progress bar visible first + main_window.progress_bar.setVisible(True) -def test_update_file_list(main_window): - """ - Test that the file list widget updates correctly. - To update the file list, we manipulate the merger and call update_file_list(). - """ - files = ["C:/path/one.pptx", "C:/path/two.pptx"] - # Add files to the merger - main_window.merger.add_files(files) - # Update the GUI to reflect the changes - main_window.update_file_list() - - assert main_window.file_list_widget.count() == 2 - assert main_window.file_list_widget.item(0).text() == "C:/path/one.pptx" - assert main_window.file_list_widget.item(1).text() == "C:/path/two.pptx" - - -def test_update_progress(main_window): - """ - Test that the progress bar updates correctly. - The update_progress method takes (value, total) parameters. - """ - main_window.update_progress(1, 2) + # Simulate progress updates + main_window._on_merge_progress(1, 2) assert main_window.progress_bar.value() == 50 - main_window.update_progress(2, 4) + main_window._on_merge_progress(2, 4) assert main_window.progress_bar.value() == 50 + main_window._on_merge_progress(4, 4) + assert main_window.progress_bar.value() == 100 + + +def test_clear_button_click(main_window, qtbot, mocker): + """Test that clicking the clear button clears files.""" + # Add some files + files = ["/path/to/file1.pptx", "/path/to/file2.pptx"] + main_window.file_model.add_files(files) + + # Mock the signal to verify it's emitted + mocker.patch.object(main_window, 'clear_requested') + + # Click the clear button + qtbot.mouseClick(main_window.clear_button, Qt.LeftButton) + + # Verify files are cleared + assert main_window.file_model.rowCount() == 0 + main_window.clear_requested.emit.assert_called_once() + + +def test_merge_button_enabled_state(main_window): + """Test that merge button is enabled only with 2+ files.""" + # Initially disabled + assert not main_window.merge_button.isEnabled() + + # Add one file - should still be disabled + main_window.file_model.add_files(["/path/to/file1.pptx"]) + assert not main_window.merge_button.isEnabled() + + # Add second file - should be enabled + main_window.file_model.add_files(["/path/to/file2.pptx"]) + assert main_window.merge_button.isEnabled() + + +def test_reject_duplicate_files(main_window): + """Test that duplicate files are rejected.""" + file = "/path/to/file1.pptx" + + # Add file first time - should succeed + rejected = main_window.file_model.add_files([file]) + assert rejected == [] + assert main_window.file_model.rowCount() == 1 + + # Try to add same file again - should be rejected + rejected = main_window.file_model.add_files([file]) + assert len(rejected) == 1 + assert rejected[0] == file + assert main_window.file_model.rowCount() == 1 # Count unchanged + + +def test_reject_non_pptx_files(main_window): + """Test that non-.pptx files are rejected.""" + invalid_files = ["/path/to/file.txt", "/path/to/file.pdf"] + rejected = main_window.file_model.add_files(invalid_files) + + assert len(rejected) == 2 + assert main_window.file_model.rowCount() == 0 -def test_add_button_click(main_window, qtbot, mocker): - """ - Test that clicking the 'Add Files' button calls the add_files method. - """ - # Mock the add_files method to verify it's called - mocker.patch.object(main_window, 'add_files') - qtbot.mouseClick(main_window.add_button, Qt.LeftButton) - main_window.add_files.assert_called_once() - - -def test_remove_button_click(main_window, qtbot, mocker): - """ - Test that clicking the 'Remove Selected' button calls the remove_selected_files method. - """ - # Add files and select one to enable the remove button - main_window.merger.add_files(['file1.pptx', 'file2.pptx']) - main_window.update_file_list() - # Select an item (this triggers selection changed signal) - item = main_window.file_list_widget.item(0) - item.setSelected(True) - main_window.file_list_widget.setCurrentItem(item) - # Force update button states - main_window.update_button_states() - - # Mock the remove_selected_files method - mocker.patch.object(main_window, 'remove_selected_files') - qtbot.mouseClick(main_window.remove_button, Qt.LeftButton) - main_window.remove_selected_files.assert_called_once() - - -def test_merge_button_click(main_window, qtbot, mocker): - """ - Test that clicking the 'Merge Files' button calls the merge_files method. - """ - # Add at least 2 files to enable the merge button - main_window.merger.add_files(['file1.pptx', 'file2.pptx']) - main_window.update_file_list() - - # Mock the merge_files method - mocker.patch.object(main_window, 'merge_files') - qtbot.mouseClick(main_window.merge_button, Qt.LeftButton) - main_window.merge_files.assert_called_once()