Procházet zdrojové kódy

feat(ui): Implement Epic 7b Phase 1 - Core UI Framework (P0)

Implements Story 7.7, 7.8, 7.9 for the core UI framework:

## Story 7.8: FileSelector Component (4 SP)
- Created src/ui/file_selector.py
- File browser button with .txt filtering
- File path display with QFileSystemWatcher for change detection
- File information preview (size, line count, estimated words)
- Signals: file_selected, file_changed, selection_cleared
- Properties: current_path, is_valid, line_count

## Story 7.9: ProgressWidget Component (5 SP)
- Created src/ui/progress_widget.py
- Implements ProgressObserver interface for scheduler integration
- Overall progress bar with percentage display
- Current stage display with color-coded status
- Scrollable chapter status list with ChapterProgressItem
- ETA calculation and real-time updates (10 FPS)
- Chapter progress items with status icons and error display
- Full pipeline lifecycle support (start/pause/resume/complete/failed)

## Testing
- Added tests/ui/test_file_selector.py with 15 test cases
- Added tests/ui/test_progress_widget.py with 20+ test cases
- Tests skip gracefully when PyQt6 is not installed

## Dependencies
- Added PyQt6==6.8.0 to requirements.txt

## Components Exported
- FileSelector: File selection widget with preview
- ProgressWidget: Progress display with ProgressNotifier connection
- ChapterProgressItem: Individual chapter progress widget

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
d8dfun před 2 dny
rodič
revize
bb217c2338

+ 3 - 0
requirements.txt

@@ -16,3 +16,6 @@ pytest-cov==4.1.0
 # Utilities
 pyyaml==6.0.1
 requests==2.31.0
+
+# UI (PyQt6 for Epic 7b)
+PyQt6==6.8.0

+ 64 - 0
src/ui/__init__.py

@@ -0,0 +1,64 @@
+"""
+UI module for BMAD Novel Translator.
+
+Provides the user interface components for the translation application.
+
+The module can be imported without PyQt6 for testing models only.
+"""
+
+# Data models (no PyQt6 required)
+from .models import (
+    FileItem,
+    TranslationTask,
+    FileStatus,
+    TaskStatus,
+    ProgressUpdate,
+    Statistics,
+)
+
+# PyQt6-dependent imports (may fail if PyQt6 not installed)
+try:
+    from .main_window import MainWindow
+    from .file_list_model import FileListModel
+
+    _pyqt6_available = True
+except ImportError:
+    _pyqt6_available = False
+    # Create placeholder for type checking
+    MainWindow = None  # type: ignore
+    FileListModel = None  # type: ignore
+
+# Other components (to be implemented)
+try:
+    from .file_selector import FileSelector
+except ImportError:
+    FileSelector = None  # type: ignore
+
+try:
+    from .progress_widget import ProgressWidget, ChapterProgressItem
+except ImportError:
+    ProgressWidget = None  # type: ignore
+    ChapterProgressItem = None  # type: ignore
+
+__all__ = [
+    # Main window (PyQt6 required)
+    "MainWindow",
+    # Data models (no dependencies)
+    "FileItem",
+    "TranslationTask",
+    "FileStatus",
+    "TaskStatus",
+    "ProgressUpdate",
+    "Statistics",
+    # Qt model (PyQt6 required)
+    "FileListModel",
+    # UI components (to be implemented)
+    "FileSelector",
+    "ProgressWidget",
+    "ChapterProgressItem",
+]
+
+
+def is_pyqt6_available() -> bool:
+    """Check if PyQt6 is available."""
+    return _pyqt6_available

+ 112 - 0
src/ui/application.py

@@ -0,0 +1,112 @@
+"""
+Application class for BMAD Novel Translator.
+
+Main application entry point and initialization.
+"""
+
+from PyQt6.QtWidgets import QApplication, QMessageBox
+from PyQt6.QtCore import Qt, QSettings, QTimer
+from PyQt6.QtGui import QIcon
+import sys
+from pathlib import Path
+
+from .main_window import MainWindow
+
+
+class TranslatorApplication(QApplication):
+    """
+    Main application class.
+
+    Handles application lifecycle, settings, and global state.
+    """
+
+    # Application info
+    APPLICATION_NAME = "BMAD Novel Translator"
+    ORGANIZATION_NAME = "BMAD"
+    APPLICATION_VERSION = "0.1.0"
+
+    def __init__(self, argv: list[str]) -> None:
+        """Initialize the application."""
+        super().__init__(argv)
+
+        self._setup_application()
+        self._load_settings()
+        self._create_main_window()
+
+    def _setup_application(self) -> None:
+        """Set up application properties."""
+        self.setApplicationName(self.APPLICATION_NAME)
+        self.setOrganizationName(self.ORGANIZATION_NAME)
+        self.setApplicationVersion(self.APPLICATION_VERSION)
+
+        # Enable high DPI scaling
+        self.setHighDpiScaleFactorRoundingPolicy(
+            Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
+        )
+
+    def _load_settings(self) -> None:
+        """Load application settings."""
+        self._settings = QSettings()
+        # Settings will be expanded in Story 7.10
+
+    def _create_main_window(self) -> None:
+        """Create and show the main window."""
+        self._main_window = MainWindow()
+
+        # Restore window geometry
+        geometry = self._settings.value("mainWindow/geometry")
+        if geometry:
+            self._main_window.restoreGeometry(geometry)
+
+        window_state = self._settings.value("mainWindow/windowState")
+        if window_state:
+            self._main_window.restoreState(window_state)
+
+        self._main_window.show()
+
+    @property
+    def main_window(self) -> MainWindow:
+        """Get the main window instance."""
+        return self._main_window
+
+    @property
+    def settings(self) -> QSettings:
+        """Get application settings."""
+        return self._settings
+
+    def save_settings(self) -> None:
+        """Save application settings."""
+        self._settings.setValue("mainWindow/geometry", self._main_window.saveGeometry())
+        self._settings.setValue("mainWindow/windowState", self._main_window.saveState())
+        self._settings.sync()
+
+
+def main() -> int:
+    """
+    Application entry point.
+
+    Returns:
+        Exit code (0 for success, non-zero for error).
+    """
+    # Enable high DPI display
+    QApplication.setHighDpiScaleFactorRoundingPolicy(
+        Qt.HighDpiScaleFactorRoundingPolicy.PassThrough
+    )
+
+    # Create application
+    app = TranslatorApplication(sys.argv)
+
+    # Handle close event to save settings
+    def on_about_to_quit() -> None:
+        app.save_settings()
+
+    app.aboutToQuit.connect(on_about_to_quit)
+
+    # Run event loop
+    exit_code = app.exec()
+
+    return exit_code
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 142 - 0
src/ui/file_list_model.py

@@ -0,0 +1,142 @@
+"""
+File List Model for UI.
+
+Provides a Qt model for displaying file items in a table view.
+"""
+
+from PyQt6.QtCore import QAbstractTableModel, Qt, QVariant
+from PyQt6.QtGui import QColor, QFont
+from typing import Any, List
+
+from .models import FileItem, FileStatus
+
+
+class FileListModel(QAbstractTableModel):
+    """
+    Table model for displaying file items.
+
+    Columns:
+    0 - Name
+    1 - Size
+    2 - Chapters
+    3 - Words
+    4 - Status
+    """
+
+    # Column definitions
+    COLUMN_NAME = 0
+    COLUMN_SIZE = 1
+    COLUMN_CHAPTERS = 2
+    COLUMN_WORDS = 3
+    COLUMN_STATUS = 4
+    COLUMN_COUNT = 5
+
+    # Column headers
+    HEADERS = ["Name", "Size", "Chapters", "Words", "Status"]
+
+    # Status colors
+    STATUS_COLORS = {
+        FileStatus.PENDING: QColor(120, 120, 120),      # Gray
+        FileStatus.IMPORTING: QColor(0, 120, 215),       # Blue
+        FileStatus.READY: QColor(0, 150, 100),           # Green
+        FileStatus.TRANSLATING: QColor(255, 140, 0),     # Orange
+        FileStatus.PAUSED: QColor(200, 100, 0),          # Dark Orange
+        FileStatus.COMPLETED: QColor(0, 180, 0),         # Dark Green
+        FileStatus.FAILED: QColor(200, 0, 0),            # Red
+    }
+
+    def __init__(self, file_items: List[FileItem], parent: Any = None) -> None:
+        """Initialize the model."""
+        super().__init__(parent)
+        self._file_items = file_items
+
+    def rowCount(self, parent: Any = None) -> int:
+        """Return the number of rows."""
+        return len(self._file_items)
+
+    def columnCount(self, parent: Any = None) -> int:
+        """Return the number of columns."""
+        return self.COLUMN_COUNT
+
+    def data(self, index: Any, role: int = Qt.ItemDataRole.DisplayRole) -> QVariant:
+        """Return data for the given index and role."""
+        if not index.isValid():
+            return QVariant()
+
+        row = index.row()
+        col = index.column()
+
+        if row >= len(self._file_items):
+            return QVariant()
+
+        item = self._file_items[row]
+
+        if role == Qt.ItemDataRole.DisplayRole:
+            return self._get_display_data(item, col)
+
+        if role == Qt.ItemDataRole.TextAlignmentRole:
+            return self._get_alignment(col)
+
+        if role == Qt.ItemDataRole.ForegroundRole:
+            if col == self.COLUMN_STATUS:
+                return self.STATUS_COLORS.get(item.status, QColor(0, 0, 0))
+
+        if role == Qt.ItemDataRole.FontRole:
+            if item.status == FileStatus.TRANSLATING:
+                font = QFont()
+                font.setBold(True)
+                return font
+
+        if role == Qt.ItemDataRole.ToolTipRole:
+            if item.error_message:
+                return item.error_message
+            return f"Path: {item.path}\nAdded: {item.added_time.strftime('%Y-%m-%d %H:%M')}"
+
+        return QVariant()
+
+    def _get_display_data(self, item: FileItem, col: int) -> QVariant:
+        """Get display data for a column."""
+        if col == self.COLUMN_NAME:
+            return item.name
+
+        if col == self.COLUMN_SIZE:
+            return item.size_formatted
+
+        if col == self.COLUMN_CHAPTERS:
+            return str(item.chapters) if item.chapters > 0 else "-"
+
+        if col == self.COLUMN_WORDS:
+            return f"{item.translated_words:,} / {item.total_words:,}" if item.total_words > 0 else "-"
+
+        if col == self.COLUMN_STATUS:
+            return item.status.value.capitalize()
+
+        return QVariant()
+
+    def _get_alignment(self, col: int) -> Qt.AlignmentFlag:
+        """Get text alignment for a column."""
+        if col in (self.COLUMN_SIZE, self.COLUMN_CHAPTERS, self.COLUMN_WORDS):
+            return Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
+        return Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
+
+    def headerData(
+        self,
+        section: int,
+        orientation: Qt.Orientation,
+        role: int = Qt.ItemDataRole.DisplayRole
+    ) -> QVariant:
+        """Return header data."""
+        if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole:
+            if 0 <= section < len(self.HEADERS):
+                return self.HEADERS[section]
+        return QVariant()
+
+    def flags(self, index: Any) -> Qt.ItemFlag:
+        """Return item flags."""
+        if not index.isValid():
+            return Qt.ItemFlag.NoItemFlags
+        return (
+            Qt.ItemFlag.ItemIsEnabled |
+            Qt.ItemFlag.ItemIsSelectable |
+            Qt.ItemFlag.ItemNeverHasChildren
+        )

+ 317 - 0
src/ui/file_selector.py

@@ -0,0 +1,317 @@
+"""
+File Selector component for UI.
+
+Provides a widget for selecting and previewing files for translation.
+"""
+
+from PyQt6.QtWidgets import (
+    QWidget,
+    QVBoxLayout,
+    QHBoxLayout,
+    QPushButton,
+    QLabel,
+    QLineEdit,
+    QFileDialog,
+    QGroupBox,
+    QSizePolicy,
+)
+from PyQt6.QtCore import Qt, pyqtSignal, QFileSystemWatcher
+from PyQt6.QtGui import QFont
+from pathlib import Path
+from typing import Optional
+
+
+class FileSelector(QWidget):
+    """
+    File selection widget with preview.
+
+    Features:
+    - File browser button with .txt filtering
+    - File path display
+    - File information preview (size, line count)
+    - Change notification via file system watcher
+    """
+
+    # Signals
+    file_selected = pyqtSignal(object)  # Path
+    file_changed = pyqtSignal(object)  # Path
+    selection_cleared = pyqtSignal()
+
+    # Constants
+    FILE_FILTER = "Text Files (*.txt);;All Files (*)"
+    DEFAULT_PLACEHOLDER = "No file selected"
+    SIZE_FORMAT_UNITS = ["B", "KB", "MB", "GB"]
+
+    def __init__(self, parent: Optional[QWidget] = None) -> None:
+        """Initialize the file selector."""
+        super().__init__(parent)
+
+        self._current_path: Optional[Path] = None
+        self._file_watcher = QFileSystemWatcher(self)
+
+        self._setup_ui()
+        self._connect_signals()
+
+    def _setup_ui(self) -> None:
+        """Set up the UI components."""
+        layout = QVBoxLayout(self)
+        layout.setContentsMargins(8, 8, 8, 8)
+        layout.setSpacing(8)
+
+        # File path input group
+        path_group = QGroupBox("Source File")
+        path_layout = QVBoxLayout(path_group)
+        path_layout.setSpacing(4)
+
+        # Path display
+        path_input_layout = QHBoxLayout()
+        self._path_display = QLineEdit(self.DEFAULT_PLACEHOLDER)
+        self._path_display.setReadOnly(True)
+        self._path_display.setMinimumWidth(300)
+        path_input_layout.addWidget(self._path_display)
+
+        # Browse button
+        self._browse_btn = QPushButton("Browse...")
+        self._browse_btn.setMinimumWidth(100)
+        path_input_layout.addWidget(self._browse_btn)
+
+        path_layout.addLayout(path_input_layout)
+
+        # Clear button
+        clear_layout = QHBoxLayout()
+        clear_layout.addStretch()
+        self._clear_btn = QPushButton("Clear Selection")
+        self._clear_btn.setEnabled(False)
+        clear_layout.addWidget(self._clear_btn)
+        path_layout.addLayout(clear_layout)
+
+        layout.addWidget(path_group)
+
+        # File info preview group
+        info_group = QGroupBox("File Information")
+        info_layout = QVBoxLayout(info_group)
+        info_layout.setSpacing(4)
+
+        # File name
+        name_layout = QHBoxLayout()
+        name_label = QLabel("Name:")
+        name_label.setFont(QFont("", 9, QFont.Weight.Bold))
+        name_label.setMinimumWidth(80)
+        name_layout.addWidget(name_label)
+        self._name_value = QLabel("-")
+        name_layout.addWidget(self._name_value, 1)
+        info_layout.addLayout(name_layout)
+
+        # File size
+        size_layout = QHBoxLayout()
+        size_label = QLabel("Size:")
+        size_label.setFont(QFont("", 9, QFont.Weight.Bold))
+        size_label.setMinimumWidth(80)
+        size_layout.addWidget(size_label)
+        self._size_value = QLabel("-")
+        size_layout.addWidget(self._size_value, 1)
+        info_layout.addLayout(size_layout)
+
+        # Line count
+        lines_layout = QHBoxLayout()
+        lines_label = QLabel("Lines:")
+        lines_label.setFont(QFont("", 9, QFont.Weight.Bold))
+        lines_label.setMinimumWidth(80)
+        lines_layout.addWidget(lines_label)
+        self._lines_value = QLabel("-")
+        lines_layout.addWidget(self._lines_value, 1)
+        info_layout.addLayout(lines_layout)
+
+        # Estimated words (rough estimate)
+        words_layout = QHBoxLayout()
+        words_label = QLabel("Est. Words:")
+        words_label.setFont(QFont("", 9, QFont.Weight.Bold))
+        words_label.setMinimumWidth(80)
+        words_layout.addWidget(words_label)
+        self._words_value = QLabel("-")
+        words_layout.addWidget(self._words_value, 1)
+        info_layout.addLayout(words_layout)
+
+        # Status indicator
+        status_layout = QHBoxLayout()
+        status_label = QLabel("Status:")
+        status_label.setFont(QFont("", 9, QFont.Weight.Bold))
+        status_label.setMinimumWidth(80)
+        status_layout.addWidget(status_label)
+        self._status_value = QLabel("No file selected")
+        self._status_value.setStyleSheet("color: gray;")
+        status_layout.addWidget(self._status_value, 1)
+        info_layout.addLayout(status_layout)
+
+        layout.addWidget(info_group)
+        layout.addStretch()
+
+        # Set size policy
+        self.setSizePolicy(
+            QSizePolicy.Policy.Preferred,
+            QSizePolicy.Policy.Fixed
+        )
+
+    def _connect_signals(self) -> None:
+        """Connect internal signals."""
+        self._browse_btn.clicked.connect(self._on_browse)
+        self._clear_btn.clicked.connect(self._on_clear)
+        self._file_watcher.fileChanged.connect(self._on_file_changed)
+
+    def _on_browse(self) -> None:
+        """Handle browse button click."""
+        file_path, _ = QFileDialog.getOpenFileName(
+            self,
+            "Select Text File",
+            str(Path.home()),
+            self.FILE_FILTER
+        )
+
+        if file_path:
+            self.set_file(Path(file_path))
+
+    def _on_clear(self) -> None:
+        """Handle clear button click."""
+        self.clear()
+
+    def _on_file_changed(self, path: str) -> None:
+        """Handle file system change notification."""
+        if self._current_path and str(self._current_path) == path:
+            # Re-check file existence and update info
+            if self._current_path.exists():
+                self._update_file_info()
+                self.file_changed.emit(self._current_path)
+            else:
+                # File was deleted
+                self.clear()
+
+    def _update_file_info(self) -> None:
+        """Update file information display."""
+        if not self._current_path:
+            self._clear_info()
+            return
+
+        try:
+            # Update path display
+            self._path_display.setText(str(self._current_path))
+
+            # Update name
+            self._name_value.setText(self._current_path.name)
+
+            # Update size
+            size = self._current_path.stat().st_size
+            self._size_value.setText(self._format_size(size))
+
+            # Count lines
+            try:
+                with open(self._current_path, 'r', encoding='utf-8') as f:
+                    line_count = sum(1 for _ in f)
+                self._lines_value.setText(f"{line_count:,}")
+
+                # Estimate words (rough: assume ~50 Chinese characters per line on average)
+                # This is a rough estimate for display purposes
+                est_words = line_count * 50
+                self._words_value.setText(f"~{est_words:,}")
+
+            except (UnicodeDecodeError, IOError) as e:
+                self._lines_value.setText("Error reading file")
+                self._words_value.setText("-")
+                self._set_status(f"Read error: {e}", is_error=True)
+                return
+
+            # Update status
+            self._set_status("File ready", is_error=False)
+            self._clear_btn.setEnabled(True)
+
+        except FileNotFoundError:
+            self._set_status("File not found", is_error=True)
+            self._clear_btn.setEnabled(False)
+        except Exception as e:
+            self._set_status(f"Error: {e}", is_error=True)
+            self._clear_btn.setEnabled(False)
+
+    def _clear_info(self) -> None:
+        """Clear all information display."""
+        self._path_display.setText(self.DEFAULT_PLACEHOLDER)
+        self._name_value.setText("-")
+        self._size_value.setText("-")
+        self._lines_value.setText("-")
+        self._words_value.setText("-")
+        self._set_status("No file selected", is_error=False)
+        self._clear_btn.setEnabled(False)
+
+    def _set_status(self, message: str, is_error: bool) -> None:
+        """Set status message with appropriate styling."""
+        self._status_value.setText(message)
+        if is_error:
+            self._status_value.setStyleSheet("color: #c0392b;")  # Red
+        else:
+            self._status_value.setStyleSheet("color: #27ae60;")  # Green
+
+    def _format_size(self, size_bytes: int) -> str:
+        """
+        Format file size for display.
+
+        Args:
+            size_bytes: Size in bytes
+
+        Returns:
+            Formatted size string
+        """
+        size = float(size_bytes)
+        for unit in self.SIZE_FORMAT_UNITS:
+            if size < 1024.0:
+                return f"{size:.1f} {unit}"
+            size /= 1024.0
+        return f"{size:.1f} TB"
+
+    def set_file(self, path: Path) -> None:
+        """
+        Set the selected file.
+
+        Args:
+            path: Path to the selected file
+        """
+        # Remove old path from watcher
+        if self._current_path:
+            self._file_watcher.removePath(str(self._current_path))
+
+        self._current_path = path
+
+        # Add new path to watcher
+        if path.exists():
+            self._file_watcher.addPath(str(path))
+
+        self._update_file_info()
+        self.file_selected.emit(path)
+
+    def clear(self) -> None:
+        """Clear the file selection."""
+        # Remove path from watcher
+        if self._current_path:
+            self._file_watcher.removePath(str(self._current_path))
+
+        self._current_path = None
+        self._clear_info()
+        self.selection_cleared.emit()
+
+    @property
+    def current_path(self) -> Optional[Path]:
+        """Get the currently selected file path."""
+        return self._current_path
+
+    @property
+    def is_valid(self) -> bool:
+        """Check if a valid file is selected."""
+        return self._current_path is not None and self._current_path.exists()
+
+    @property
+    def line_count(self) -> Optional[int]:
+        """Get the line count of the selected file."""
+        if not self.is_valid:
+            return None
+        try:
+            with open(self._current_path, 'r', encoding='utf-8') as f:
+                return sum(1 for _ in f)
+        except (UnicodeDecodeError, IOError):
+            return None

+ 491 - 0
src/ui/main_window.py

@@ -0,0 +1,491 @@
+"""
+Main Window for BMAD Novel Translator.
+
+Implements the primary application window with menu bar, toolbar,
+and central workspace.
+"""
+
+from PyQt6.QtWidgets import (
+    QMainWindow,
+    QWidget,
+    QVBoxLayout,
+    QHBoxLayout,
+    QMenuBar,
+    QToolBar,
+    QStatusBar,
+    QSplitter,
+    QLabel,
+    QPushButton,
+    QTableView,
+    QHeaderView,
+)
+from PyQt6.QtCore import Qt, QSize, pyqtSignal, QTimer
+from PyQt6.QtGui import QAction, QIcon, QKeySequence, QFont
+
+from .models import FileItem, TranslationTask, FileStatus, TaskStatus
+from .file_list_model import FileListModel
+
+
+class MainWindow(QMainWindow):
+    """
+    Main application window.
+
+    Features:
+    - Menu bar with File, Edit, View, Tools, Help menus
+    - Toolbar with common actions
+    - Central workspace with file list and task controls
+    - Status bar showing current status and version
+    """
+
+    # Signals
+    file_added = pyqtSignal(object)  # FileItem
+    file_removed = pyqtSignal(object)  # FileItem
+    translation_started = pyqtSignal()
+    translation_paused = pyqtSignal()
+    translation_cancelled = pyqtSignal()
+    settings_requested = pyqtSignal()
+    about_requested = pyqtSignal()
+
+    # Constants
+    DEFAULT_WIDTH = 1200
+    DEFAULT_HEIGHT = 800
+    MINIMUM_WIDTH = 900
+    MINIMUM_HEIGHT = 600
+    APPLICATION_TITLE = "BMAD Novel Translator"
+    APPLICATION_VERSION = "0.1.0"
+
+    def __init__(self, parent: QWidget | None = None) -> None:
+        """Initialize the main window."""
+        super().__init__(parent)
+
+        self._file_items: list[FileItem] = []
+        self._current_task: TranslationTask | None = None
+
+        self._setup_ui()
+        self._create_menu_bar()
+        self._create_toolbar()
+        self._create_status_bar()
+        self._connect_signals()
+
+    def _setup_ui(self) -> None:
+        """Set up the main window UI components."""
+        self.setWindowTitle(self.APPLICATION_TITLE)
+        self.resize(self.DEFAULT_WIDTH, self.DEFAULT_HEIGHT)
+        self.setMinimumSize(self.MINIMUM_WIDTH, self.MINIMUM_HEIGHT)
+
+        # Create central widget
+        central_widget = QWidget()
+        self.setCentralWidget(central_widget)
+
+        # Main layout
+        main_layout = QVBoxLayout(central_widget)
+        main_layout.setContentsMargins(8, 8, 8, 8)
+        main_layout.setSpacing(8)
+
+        # Create splitter for resizable panes
+        splitter = QSplitter(Qt.Orientation.Horizontal)
+        main_layout.addWidget(splitter)
+
+        # Left panel - File list
+        left_panel = self._create_file_list_panel()
+        splitter.addWidget(left_panel)
+
+        # Right panel - Task controls and progress
+        right_panel = self._create_task_panel()
+        splitter.addWidget(right_panel)
+
+        # Set initial splitter sizes (60% left, 40% right)
+        splitter.setSizes([600, 400])
+
+    def _create_file_list_panel(self) -> QWidget:
+        """Create the file list panel."""
+        panel = QWidget()
+        layout = QVBoxLayout(panel)
+        layout.setContentsMargins(0, 0, 0, 0)
+        layout.setSpacing(4)
+
+        # Header
+        header_layout = QHBoxLayout()
+        title_label = QLabel("Files")
+        title_label.setFont(QFont("", 12, QFont.Weight.Bold))
+        header_layout.addWidget(title_label)
+        header_layout.addStretch()
+
+        # Add file buttons
+        self._add_file_btn = QPushButton("Add Files")
+        self._add_folder_btn = QPushButton("Add Folder")
+        self._remove_file_btn = QPushButton("Remove")
+        self._clear_all_btn = QPushButton("Clear All")
+
+        header_layout.addWidget(self._add_file_btn)
+        header_layout.addWidget(self._add_folder_btn)
+        header_layout.addWidget(self._remove_file_btn)
+        header_layout.addWidget(self._clear_all_btn)
+        layout.addLayout(header_layout)
+
+        # File table
+        self._file_table = QTableView()
+        self._file_model = FileListModel(self._file_items)
+        self._file_table.setModel(self._file_model)
+
+        # Configure table
+        self._file_table.setSelectionBehavior(QTableView.SelectionBehavior.SelectRows)
+        self._file_table.setSelectionMode(QTableView.SelectionMode.ExtendedSelection)
+        self._file_table.setAlternatingRowColors(True)
+        self._file_table.horizontalHeader().setStretchLastSection(True)
+        self._file_table.horizontalHeader().setSectionResizeMode(
+            0, QHeaderView.ResizeMode.Stretch
+        )
+
+        layout.addWidget(self._file_table)
+
+        return panel
+
+    def _create_task_panel(self) -> QWidget:
+        """Create the task control and progress panel."""
+        panel = QWidget()
+        layout = QVBoxLayout(panel)
+        layout.setContentsMargins(0, 0, 0, 0)
+        layout.setSpacing(8)
+
+        # Task controls section
+        controls_group = QWidget()
+        controls_layout = QVBoxLayout(controls_group)
+        controls_layout.setSpacing(8)
+
+        # Control buttons
+        buttons_layout = QHBoxLayout()
+        self._start_btn = QPushButton("Start Translation")
+        self._start_btn.setEnabled(False)
+        self._pause_btn = QPushButton("Pause")
+        self._pause_btn.setEnabled(False)
+        self._cancel_btn = QPushButton("Cancel")
+        self._cancel_btn.setEnabled(False)
+
+        self._start_btn.setMinimumHeight(40)
+        self._pause_btn.setMinimumHeight(40)
+        self._cancel_btn.setMinimumHeight(40)
+
+        buttons_layout.addWidget(self._start_btn)
+        buttons_layout.addWidget(self._pause_btn)
+        buttons_layout.addWidget(self._cancel_btn)
+        controls_layout.addLayout(buttons_layout)
+
+        # Progress section
+        progress_group = QWidget()
+        progress_layout = QVBoxLayout(progress_group)
+        progress_layout.setSpacing(4)
+
+        progress_label = QLabel("Translation Progress")
+        progress_label.setFont(QFont("", 11, QFont.Weight.Bold))
+        progress_layout.addWidget(progress_label)
+
+        # Task status
+        self._task_status_label = QLabel("No active task")
+        self._task_status_label.setStyleSheet("color: gray;")
+        progress_layout.addWidget(self._task_status_label)
+
+        # Progress bar (will be added in Story 7.9)
+        from PyQt6.QtWidgets import QProgressBar
+        self._progress_bar = QProgressBar()
+        self._progress_bar.setRange(0, 100)
+        self._progress_bar.setValue(0)
+        self._progress_bar.setTextVisible(True)
+        progress_layout.addWidget(self._progress_bar)
+
+        # Statistics
+        stats_layout = QHBoxLayout()
+        self._files_label = QLabel("Files: 0")
+        self._chapters_label = QLabel("Chapters: 0")
+        self._words_label = QLabel("Words: 0")
+        self._time_label = QLabel("ETA: --:--")
+
+        stats_layout.addWidget(self._files_label)
+        stats_layout.addWidget(self._chapters_label)
+        stats_layout.addWidget(self._words_label)
+        stats_layout.addStretch()
+        stats_layout.addWidget(self._time_label)
+        progress_layout.addLayout(stats_layout)
+
+        layout.addWidget(controls_group)
+        layout.addWidget(progress_group)
+        layout.addStretch()
+
+        return panel
+
+    def _create_menu_bar(self) -> None:
+        """Create the application menu bar."""
+        menubar = self.menuBar()
+
+        # File Menu
+        file_menu = menubar.addMenu("&File")
+
+        add_files_action = QAction("Add &Files...", self)
+        add_files_action.setShortcut(QKeySequence.StandardKey.Open)
+        add_files_action.setStatusTip("Add files to translation queue")
+        add_files_action.triggered.connect(self._on_add_files)
+        file_menu.addAction(add_files_action)
+
+        add_folder_action = QAction("Add &Folder...", self)
+        add_folder_action.setStatusTip("Add all files from a folder")
+        add_folder_action.triggered.connect(self._on_add_folder)
+        file_menu.addAction(add_folder_action)
+
+        file_menu.addSeparator()
+
+        export_action = QAction("E&xport Results...", self)
+        export_action.setShortcut(QKeySequence.StandardKey.Save)
+        export_action.setStatusTip("Export translation results")
+        export_action.setEnabled(False)  # TODO: Implement in Story 7.17
+        file_menu.addAction(export_action)
+
+        file_menu.addSeparator()
+
+        exit_action = QAction("E&xit", self)
+        exit_action.setShortcut(QKeySequence.StandardKey.Quit)
+        exit_action.setStatusTip("Exit application")
+        exit_action.triggered.connect(self.close)
+        file_menu.addAction(exit_action)
+
+        # Edit Menu
+        edit_menu = menubar.addMenu("&Edit")
+
+        select_all_action = QAction("Select &All", self)
+        select_all_action.setShortcut(QKeySequence.StandardKey.SelectAll)
+        select_all_action.setStatusTip("Select all files")
+        select_all_action.triggered.connect(self._on_select_all)
+        edit_menu.addAction(select_all_action)
+
+        edit_menu.addSeparator()
+
+        settings_action = QAction("&Settings...", self)
+        settings_action.setShortcut(QKeySequence.StandardKey.Preferences)
+        settings_action.setStatusTip("Open settings dialog")
+        settings_action.triggered.connect(self._on_settings)
+        edit_menu.addAction(settings_action)
+
+        # View Menu
+        view_menu = menubar.addMenu("&View")
+
+        log_viewer_action = QAction("&Log Viewer...", self)
+        log_viewer_action.setStatusTip("View application logs")
+        log_viewer_action.setEnabled(False)  # TODO: Implement in Story 7.15
+        view_menu.addAction(log_viewer_action)
+
+        statistics_action = QAction("&Statistics...", self)
+        statistics_action.setStatusTip("View translation statistics")
+        statistics_action.setEnabled(False)  # TODO: Implement in Story 7.23
+        view_menu.addAction(statistics_action)
+
+        # Tools Menu
+        tools_menu = menubar.addMenu("&Tools")
+
+        terminology_action = QAction("&Terminology Editor...", self)
+        terminology_action.setStatusTip("Manage terminology")
+        terminology_action.setEnabled(False)  # TODO: Implement in Story 7.19
+        tools_menu.addAction(terminology_action)
+
+        # Help Menu
+        help_menu = menubar.addMenu("&Help")
+
+        documentation_action = QAction("&Documentation", self)
+        documentation_action.setShortcut(QKeySequence.StandardKey.HelpContents)
+        documentation_action.setStatusTip("View documentation")
+        documentation_action.setEnabled(False)
+        help_menu.addAction(documentation_action)
+
+        help_menu.addSeparator()
+
+        about_action = QAction("&About", self)
+        about_action.setStatusTip("About this application")
+        about_action.triggered.connect(self._on_about)
+        help_menu.addAction(about_action)
+
+    def _create_toolbar(self) -> None:
+        """Create the main toolbar."""
+        toolbar = QToolBar("Main Toolbar")
+        toolbar.setMovable(False)
+        self.addToolBar(toolbar)
+
+        # Add Files action
+        add_files_action = QAction("Add Files", self)
+        add_files_action.setStatusTip("Add files to translation queue")
+        add_files_action.triggered.connect(self._on_add_files)
+        toolbar.addAction(add_files_action)
+
+        # Add Folder action
+        add_folder_action = QAction("Add Folder", self)
+        add_folder_action.setStatusTip("Add folder to translation queue")
+        add_folder_action.triggered.connect(self._on_add_folder)
+        toolbar.addAction(add_folder_action)
+
+        toolbar.addSeparator()
+
+        # Start action
+        self._start_action = QAction("Start", self)
+        self._start_action.setStatusTip("Start translation")
+        self._start_action.setEnabled(False)
+        self._start_action.triggered.connect(self._on_start_translation)
+        toolbar.addAction(self._start_action)
+
+        # Pause action
+        self._pause_action = QAction("Pause", self)
+        self._pause_action.setStatusTip("Pause translation")
+        self._pause_action.setEnabled(False)
+        self._pause_action.triggered.connect(self._on_pause_translation)
+        toolbar.addAction(self._pause_action)
+
+        # Cancel action
+        self._cancel_action = QAction("Cancel", self)
+        self._cancel_action.setStatusTip("Cancel translation")
+        self._cancel_action.setEnabled(False)
+        self._cancel_action.triggered.connect(self._on_cancel_translation)
+        toolbar.addAction(self._cancel_action)
+
+        toolbar.addSeparator()
+
+        # Settings action
+        settings_action = QAction("Settings", self)
+        settings_action.setStatusTip("Open settings")
+        settings_action.triggered.connect(self._on_settings)
+        toolbar.addAction(settings_action)
+
+    def _create_status_bar(self) -> None:
+        """Create the status bar."""
+        status_bar = QStatusBar()
+        self.setStatusBar(status_bar)
+
+        # Status label
+        self._status_label = QLabel("Ready")
+        status_bar.addWidget(self._status_label)
+
+        # Permanent widgets (right side)
+        status_bar.addPermanentWidget(QLabel(f"v{self.APPLICATION_VERSION}"))
+
+    def _connect_signals(self) -> None:
+        """Connect internal signals."""
+        # Button signals
+        self._add_file_btn.clicked.connect(self._on_add_files)
+        self._add_folder_btn.clicked.connect(self._on_add_folder)
+        self._remove_file_btn.clicked.connect(self._on_remove_files)
+        self._clear_all_btn.clicked.connect(self._on_clear_all)
+        self._start_btn.clicked.connect(self._on_start_translation)
+        self._pause_btn.clicked.connect(self._on_pause_translation)
+        self._cancel_btn.clicked.connect(self._on_cancel_translation)
+
+        # Selection change
+        self._file_table.selectionModel().selectionChanged.connect(
+            self._on_selection_changed
+        )
+
+    def _on_add_files(self) -> None:
+        """Handle add files button click."""
+        # TODO: Implement file selection dialog in Story 7.8
+        self._status_label.setText("Add files - TODO: Implement file dialog")
+
+    def _on_add_folder(self) -> None:
+        """Handle add folder button click."""
+        # TODO: Implement folder selection dialog in Story 7.8
+        self._status_label.setText("Add folder - TODO: Implement folder dialog")
+
+    def _on_remove_files(self) -> None:
+        """Handle remove files button click."""
+        # TODO: Implement file removal logic
+        self._status_label.setText("Remove files - TODO")
+
+    def _on_clear_all(self) -> None:
+        """Handle clear all button click."""
+        # TODO: Implement clear all logic
+        self._status_label.setText("Clear all - TODO")
+
+    def _on_select_all(self) -> None:
+        """Handle select all menu action."""
+        self._file_table.selectAll()
+
+    def _on_settings(self) -> None:
+        """Handle settings menu action."""
+        self.settings_requested.emit()
+
+    def _on_about(self) -> None:
+        """Handle about menu action."""
+        self.about_requested.emit()
+
+    def _on_start_translation(self) -> None:
+        """Handle start translation button click."""
+        self.translation_started.emit()
+
+    def _on_pause_translation(self) -> None:
+        """Handle pause translation button click."""
+        self.translation_paused.emit()
+
+    def _on_cancel_translation(self) -> None:
+        """Handle cancel translation button click."""
+        self.translation_cancelled.emit()
+
+    def _on_selection_changed(self) -> None:
+        """Handle file table selection change."""
+        has_selection = len(self._file_table.selectionModel().selectedRows()) > 0
+        self._remove_file_btn.setEnabled(has_selection)
+
+    def add_file_item(self, item: FileItem) -> None:
+        """Add a file item to the list."""
+        self._file_items.append(item)
+        self._file_model.layoutChanged.emit()
+        self._update_file_count()
+        self._update_control_buttons()
+        self.file_added.emit(item)
+
+    def remove_file_item(self, item: FileItem) -> None:
+        """Remove a file item from the list."""
+        if item in self._file_items:
+            self._file_items.remove(item)
+            self._file_model.layoutChanged.emit()
+            self._update_file_count()
+            self._update_control_buttons()
+            self.file_removed.emit(item)
+
+    def clear_all_files(self) -> None:
+        """Clear all file items."""
+        self._file_items.clear()
+        self._file_model.layoutChanged.emit()
+        self._update_file_count()
+        self._update_control_buttons()
+
+    def _update_file_count(self) -> None:
+        """Update file count display."""
+        count = len(self._file_items)
+        self._files_label.setText(f"Files: {count}")
+
+    def _update_control_buttons(self) -> None:
+        """Update control button states."""
+        has_files = len(self._file_items) > 0
+        self._start_btn.setEnabled(has_files)
+        self._start_action.setEnabled(has_files)
+        self._clear_all_btn.setEnabled(has_files)
+
+    def set_task_status(self, status: str) -> None:
+        """Update task status display."""
+        self._task_status_label.setText(status)
+
+    def set_progress(self, value: int) -> None:
+        """Update progress bar value."""
+        self._progress_bar.setValue(value)
+
+    def set_status_message(self, message: str) -> None:
+        """Update status bar message."""
+        self._status_label.setText(message)
+
+    @property
+    def file_items(self) -> list[FileItem]:
+        """Get list of file items."""
+        return self._file_items.copy()
+
+    @property
+    def selected_file_items(self) -> list[FileItem]:
+        """Get currently selected file items."""
+        selected = []
+        for index in self._file_table.selectionModel().selectedRows():
+            row = index.row()
+            if 0 <= row < len(self._file_items):
+                selected.append(self._file_items[row])
+        return selected

+ 166 - 0
src/ui/models.py

@@ -0,0 +1,166 @@
+"""
+Data models for UI components.
+
+Defines the data structures used throughout the UI layer.
+"""
+
+from dataclasses import dataclass, field
+from enum import Enum
+from typing import Optional
+from datetime import datetime
+from pathlib import Path
+
+
+class FileStatus(Enum):
+    """Status of a file in the translation queue."""
+    PENDING = "pending"
+    IMPORTING = "importing"
+    READY = "ready"
+    TRANSLATING = "translating"
+    PAUSED = "paused"
+    COMPLETED = "completed"
+    FAILED = "failed"
+
+
+class TaskStatus(Enum):
+    """Status of a translation task."""
+    IDLE = "idle"
+    RUNNING = "running"
+    PAUSED = "paused"
+    COMPLETED = "completed"
+    FAILED = "failed"
+    CANCELLED = "cancelled"
+
+
+@dataclass
+class FileItem:
+    """Represents a file in the translation queue."""
+    path: Path
+    name: str
+    size: int
+    status: FileStatus = FileStatus.PENDING
+    chapters: int = 0
+    total_words: int = 0
+    translated_words: int = 0
+    error_message: Optional[str] = None
+    added_time: datetime = field(default_factory=datetime.now)
+
+    @property
+    def progress(self) -> float:
+        """Calculate translation progress (0-100)."""
+        if self.total_words == 0:
+            return 0.0
+        return (self.translated_words / self.total_words) * 100
+
+    @property
+    def size_formatted(self) -> str:
+        """Format file size for display."""
+        for unit in ["B", "KB", "MB", "GB"]:
+            if self.size < 1024.0:
+                return f"{self.size:.1f} {unit}"
+            self.size /= 1024.0
+        return f"{self.size:.1f} TB"
+
+
+@dataclass
+class TranslationTask:
+    """Represents a translation task."""
+    id: str
+    file_items: list[FileItem] = field(default_factory=list)
+    status: TaskStatus = TaskStatus.IDLE
+    current_stage: str = ""
+    current_chapter: int = 0
+    total_chapters: int = 0
+    start_time: Optional[datetime] = None
+    end_time: Optional[datetime] = None
+    error_message: Optional[str] = None
+
+    @property
+    def progress(self) -> float:
+        """Calculate overall task progress (0-100)."""
+        if self.total_chapters == 0:
+            return 0.0
+        return (self.current_chapter / self.total_chapters) * 100
+
+    @property
+    def elapsed_time(self) -> Optional[float]:
+        """Get elapsed time in seconds."""
+        if not self.start_time:
+            return None
+        end = self.end_time or datetime.now()
+        return (end - self.start_time).total_seconds()
+
+    @property
+    def is_running(self) -> bool:
+        """Check if task is currently running."""
+        return self.status == TaskStatus.RUNNING
+
+    @property
+    def is_paused(self) -> bool:
+        """Check if task is paused."""
+        return self.status == TaskStatus.PAUSED
+
+    @property
+    def can_start(self) -> bool:
+        """Check if task can be started."""
+        return self.status in (TaskStatus.IDLE, TaskStatus.PAUSED, TaskStatus.FAILED)
+
+    @property
+    def can_pause(self) -> bool:
+        """Check if task can be paused."""
+        return self.status == TaskStatus.RUNNING
+
+    @property
+    def can_cancel(self) -> bool:
+        """Check if task can be cancelled."""
+        return self.status in (TaskStatus.RUNNING, TaskStatus.PAUSED)
+
+
+@dataclass
+class ProgressUpdate:
+    """Progress update event data."""
+    task_id: str
+    stage: str
+    current: int
+    total: int
+    message: str = ""
+    elapsed_seconds: float = 0
+    eta_seconds: Optional[float] = None
+
+    @property
+    def progress(self) -> float:
+        """Calculate progress percentage."""
+        if self.total == 0:
+            return 0.0
+        return (self.current / self.total) * 100
+
+
+@dataclass
+class Statistics:
+    """Translation statistics."""
+    total_translated_words: int = 0
+    total_chapters: int = 0
+    total_time_seconds: float = 0
+    successful_tasks: int = 0
+    failed_tasks: int = 0
+    terminology_usage_rate: float = 0.0
+
+    @property
+    def average_speed(self) -> float:
+        """Calculate average translation speed (words/minute)."""
+        if self.total_time_seconds == 0:
+            return 0.0
+        return (self.total_translated_words / self.total_time_seconds) * 60
+
+    @property
+    def completion_rate(self) -> float:
+        """Calculate task completion rate."""
+        total = self.successful_tasks + self.failed_tasks
+        if total == 0:
+            return 0.0
+        return (self.successful_tasks / total) * 100
+
+    @property
+    def total_tasks(self) -> int:
+        """Get total number of tasks."""
+        return self.successful_tasks + self.failed_tasks

+ 460 - 0
src/ui/progress_widget.py

@@ -0,0 +1,460 @@
+"""
+Progress Widget for UI.
+
+Provides a widget for displaying translation progress with
+connection to the ProgressNotifier from the scheduler.
+"""
+
+from PyQt6.QtWidgets import (
+    QWidget,
+    QVBoxLayout,
+    QHBoxLayout,
+    QProgressBar,
+    QLabel,
+    QGroupBox,
+    QScrollArea,
+    QFrame,
+    QSizePolicy,
+)
+from PyQt6.QtCore import Qt, pyqtSignal, QTimer
+from PyQt6.QtGui import QFont, QColor
+from typing import Optional, List
+from datetime import datetime
+
+from ..scheduler.progress import ProgressObserver, ProgressNotifier
+from ..scheduler.models import ChapterTask, PipelineProgress, TaskStatus, SchedulerState
+
+
+class ChapterProgressItem(QWidget):
+    """
+    Widget displaying a single chapter's progress.
+    """
+
+    # Status colors
+    STATUS_COLORS = {
+        TaskStatus.PENDING: QColor(120, 120, 120),      # Gray
+        TaskStatus.IN_PROGRESS: QColor(52, 152, 219),   # Blue
+        TaskStatus.COMPLETED: QColor(39, 174, 96),      # Green
+        TaskStatus.FAILED: QColor(192, 57, 43),         # Red
+        TaskStatus.SKIPPED: QColor(149, 165, 166),      # Gray
+        TaskStatus.RETRYING: QColor(230, 126, 34),      # Orange
+    }
+
+    STATUS_ICONS = {
+        TaskStatus.PENDING: "○",
+        TaskStatus.IN_PROGRESS: "⟳",
+        TaskStatus.COMPLETED: "✓",
+        TaskStatus.FAILED: "✗",
+        TaskStatus.SKIPPED: "⊘",
+        TaskStatus.RETRYING: "↺",
+    }
+
+    def __init__(self, task: ChapterTask, parent: Optional[QWidget] = None) -> None:
+        """Initialize the chapter progress item."""
+        super().__init__(parent)
+
+        self._task = task
+        self._setup_ui()
+
+    def _setup_ui(self) -> None:
+        """Set up the UI components."""
+        layout = QHBoxLayout(self)
+        layout.setContentsMargins(4, 4, 4, 4)
+        layout.setSpacing(8)
+
+        # Status icon
+        self._icon_label = QLabel(self.STATUS_ICONS.get(self._task.status, "○"))
+        self._icon_label.setFont(QFont("", 14))
+        layout.addWidget(self._icon_label)
+
+        # Chapter info
+        info_layout = QVBoxLayout()
+        info_layout.setSpacing(2)
+
+        # Title
+        title_layout = QHBoxLayout()
+        self._title_label = QLabel(f"Chapter {self._task.chapter_index + 1}: {self._task.title}")
+        self._title_label.setFont(QFont("", 10))
+        title_layout.addWidget(self._title_label)
+        title_layout.addStretch()
+        info_layout.addLayout(title_layout)
+
+        # Details
+        self._details_label = QLabel(self._get_details_text())
+        self._details_label.setFont(QFont("", 8))
+        self._details_label.setStyleSheet("color: gray;")
+        info_layout.addWidget(self._details_label)
+
+        layout.addLayout(info_layout, 1)
+
+        # Set background based on status
+        self._update_style()
+
+    def _get_details_text(self) -> str:
+        """Get details text based on task status."""
+        if self._task.status == TaskStatus.PENDING:
+            return "Waiting..."
+
+        if self._task.status == TaskStatus.IN_PROGRESS:
+            return "Processing..."
+
+        if self._task.status == TaskStatus.COMPLETED:
+            duration = ""
+            if self._task.duration:
+                duration = f" ({self._task.duration:.1f}s)"
+            return f"Completed{duration}"
+
+        if self._task.status == TaskStatus.FAILED:
+            error = self._task.error_message or "Unknown error"
+            return f"Failed: {error[:40]}..." if len(error) > 40 else f"Failed: {error}"
+
+        if self._task.status == TaskStatus.RETRYING:
+            return f"Retrying ({self._task.retry_count}/3)..."
+
+        if self._task.status == TaskStatus.SKIPPED:
+            return "Skipped"
+
+        return ""
+
+    def _update_style(self) -> None:
+        """Update widget styling based on task status."""
+        color = self.STATUS_COLORS.get(self._task.status, QColor(120, 120, 120))
+        icon = self.STATUS_ICONS.get(self._task.status, "○")
+
+        self._icon_label.setText(icon)
+        self._icon_label.setStyleSheet(f"color: {color.name()};")
+        self._title_label.setStyleSheet(f"color: {color.name()};")
+
+    def update_task(self, task: ChapterTask) -> None:
+        """
+        Update the displayed task.
+
+        Args:
+            task: The updated task
+        """
+        self._task = task
+        self._title_label.setText(f"Chapter {self._task.chapter_index + 1}: {self._task.title}")
+        self._details_label.setText(self._get_details_text())
+        self._update_style()
+
+
+class ProgressWidget(QWidget, ProgressObserver):
+    """
+    Progress display widget.
+
+    Features:
+    - Overall progress bar
+    - Current stage display
+    - Chapter status list (scrollable)
+    - Connection to ProgressNotifier signals
+    - ETA calculation and display
+    """
+
+    # Signals
+    cancel_requested = pyqtSignal()
+
+    # Constants
+    REFRESH_INTERVAL_MS = 100  # 10 FPS for smooth updates
+
+    def __init__(self, parent: Optional[QWidget] = None) -> None:
+        """Initialize the progress widget."""
+        QWidget.__init__(self, parent)
+        ProgressObserver.__init__(self)
+
+        self._progress: Optional[PipelineProgress] = None
+        self._chapter_items: List[ChapterProgressItem] = []
+        self._start_time: Optional[datetime] = None
+
+        # ETA update timer
+        self._eta_timer = QTimer(self)
+        self._eta_timer.timeout.connect(self._update_eta)
+
+        self._setup_ui()
+
+    def _setup_ui(self) -> None:
+        """Set up the UI components."""
+        layout = QVBoxLayout(self)
+        layout.setContentsMargins(8, 8, 8, 8)
+        layout.setSpacing(8)
+
+        # Overall progress group
+        progress_group = QGroupBox("Translation Progress")
+        progress_layout = QVBoxLayout(progress_group)
+        progress_layout.setSpacing(4)
+
+        # Stage label
+        self._stage_label = QLabel("No active translation")
+        self._stage_label.setFont(QFont("", 10, QFont.Weight.Bold))
+        self._stage_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+        progress_layout.addWidget(self._stage_label)
+
+        # Progress bar
+        self._progress_bar = QProgressBar()
+        self._progress_bar.setRange(0, 100)
+        self._progress_bar.setValue(0)
+        self._progress_bar.setTextVisible(True)
+        self._progress_bar.setMinimumHeight(25)
+        progress_layout.addWidget(self._progress_bar)
+
+        # Statistics row
+        stats_layout = QHBoxLayout()
+
+        self._completed_label = QLabel("Completed: 0")
+        self._completed_label.setMinimumWidth(100)
+        stats_layout.addWidget(self._completed_label)
+
+        self._failed_label = QLabel("Failed: 0")
+        self._failed_label.setMinimumWidth(100)
+        stats_layout.addWidget(self._failed_label)
+
+        self._remaining_label = QLabel("Remaining: 0")
+        self._remaining_label.setMinimumWidth(100)
+        stats_layout.addWidget(self._remaining_label)
+
+        stats_layout.addStretch()
+
+        self._eta_label = QLabel("ETA: --:--")
+        self._eta_label.setMinimumWidth(80)
+        self._eta_label.setStyleSheet("font-weight: bold;")
+        stats_layout.addWidget(self._eta_label)
+
+        progress_layout.addLayout(stats_layout)
+
+        layout.addWidget(progress_group)
+
+        # Chapter list group
+        chapters_group = QGroupBox("Chapters")
+        chapters_layout = QVBoxLayout(chapters_group)
+        chapters_layout.setSpacing(4)
+
+        # Scroll area for chapter list
+        scroll_area = QScrollArea()
+        scroll_area.setWidgetResizable(True)
+        scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+        scroll_area.setMinimumHeight(200)
+
+        # Container widget for chapter items
+        self._chapters_container = QWidget()
+        self._chapters_layout = QVBoxLayout(self._chapters_container)
+        self._chapters_layout.setSpacing(2)
+        self._chapters_layout.addStretch()
+
+        scroll_area.setWidget(self._chapters_container)
+        chapters_layout.addWidget(scroll_area)
+
+        layout.addWidget(chapters_group, 1)
+
+    def connect_to_notifier(self, notifier: ProgressNotifier) -> None:
+        """
+        Connect this widget to a ProgressNotifier.
+
+        Args:
+            notifier: The progress notifier to connect to
+        """
+        notifier.register(self)
+
+    def disconnect_from_notifier(self, notifier: ProgressNotifier) -> None:
+        """
+        Disconnect this widget from a ProgressNotifier.
+
+        Args:
+            notifier: The progress notifier to disconnect from
+        """
+        notifier.unregister(self)
+
+    # ProgressObserver interface implementation
+
+    def on_pipeline_start(self, total_chapters: int) -> None:
+        """Called when the pipeline starts."""
+        self._start_time = datetime.now()
+        self._progress = PipelineProgress(total_chapters=total_chapters, state=SchedulerState.RUNNING)
+        self._chapter_items.clear()
+
+        # Clear and reset container
+        while self._chapters_layout.count() > 0:
+            item = self._chapters_layout.takeAt(0)
+            if item.widget():
+                item.widget().deleteLater()
+        self._chapters_layout.addStretch()
+
+        # Update UI
+        self._stage_label.setText("Translation in progress...")
+        self._stage_label.setStyleSheet("color: #3498db;")
+        self._progress_bar.setValue(0)
+        self._update_stats()
+        self._update_eta()
+
+        # Start ETA timer
+        self._eta_timer.start(self.REFRESH_INTERVAL_MS)
+
+    def on_pipeline_complete(self, progress: PipelineProgress) -> None:
+        """Called when the pipeline completes."""
+        self._progress = progress
+        self._progress_bar.setValue(100)
+        self._stage_label.setText("Translation complete!")
+        self._stage_label.setStyleSheet("color: #27ae60;")
+        self._update_stats()
+        self._eta_timer.stop()
+        self._eta_label.setText("ETA: Done")
+
+    def on_pipeline_paused(self, progress: PipelineProgress) -> None:
+        """Called when the pipeline is paused."""
+        self._progress = progress
+        self._stage_label.setText("Translation paused")
+        self._stage_label.setStyleSheet("color: #e67e22;")
+        self._update_stats()
+        self._eta_timer.stop()
+
+    def on_pipeline_resumed(self, progress: PipelineProgress) -> None:
+        """Called when the pipeline is resumed."""
+        self._progress = progress
+        self._stage_label.setText("Translation in progress...")
+        self._stage_label.setStyleSheet("color: #3498db;")
+        self._update_stats()
+        self._eta_timer.start(self.REFRESH_INTERVAL_MS)
+
+    def on_pipeline_failed(self, error: str, progress: PipelineProgress) -> None:
+        """Called when the pipeline fails."""
+        self._progress = progress
+        self._stage_label.setText(f"Translation failed: {error}")
+        self._stage_label.setStyleSheet("color: #e74c3c;")
+        self._eta_timer.stop()
+        self._eta_label.setText("ETA: Failed")
+
+    def on_chapter_start(self, task: ChapterTask) -> None:
+        """Called when a chapter starts processing."""
+        self._add_or_update_chapter_item(task)
+        if self._progress:
+            self._progress.current_chapter = task.chapter_index
+        self._update_stats()
+
+    def on_chapter_complete(self, task: ChapterTask) -> None:
+        """Called when a chapter completes successfully."""
+        if self._progress:
+            self._progress.completed_chapters += 1
+        self._add_or_update_chapter_item(task)
+        self._update_progress_bar()
+        self._update_stats()
+
+    def on_chapter_failed(self, task: ChapterTask, error: str) -> None:
+        """Called when a chapter fails."""
+        if self._progress:
+            self._progress.failed_chapters += 1
+        self._add_or_update_chapter_item(task)
+        self._update_stats()
+
+    def on_chapter_retry(self, task: ChapterTask, attempt: int) -> None:
+        """Called when a chapter is being retried."""
+        self._add_or_update_chapter_item(task)
+
+    def on_progress(self, current: int, total: int) -> None:
+        """Called on progress update."""
+        self._update_progress_bar()
+
+    # Internal methods
+
+    def _add_or_update_chapter_item(self, task: ChapterTask) -> None:
+        """Add or update a chapter progress item."""
+        # Find existing item
+        for item in self._chapter_items:
+            if item._task.chapter_id == task.chapter_id:
+                item.update_task(task)
+                return
+
+        # Create new item
+        new_item = ChapterProgressItem(task)
+        self._chapter_items.append(new_item)
+
+        # Insert before the stretch
+        insert_index = self._chapters_layout.count() - 1
+        self._chapters_layout.insertWidget(insert_index, new_item)
+
+    def _update_progress_bar(self) -> None:
+        """Update the main progress bar."""
+        if not self._progress:
+            return
+
+        if self._progress.total_chapters > 0:
+            progress = int(self._progress.completion_rate * 100)
+            self._progress_bar.setValue(progress)
+
+    def _update_stats(self) -> None:
+        """Update statistics display."""
+        if not self._progress:
+            self._completed_label.setText("Completed: 0")
+            self._failed_label.setText("Failed: 0")
+            self._remaining_label.setText("Remaining: 0")
+            return
+
+        self._completed_label.setText(f"Completed: {self._progress.completed_chapters}")
+        self._failed_label.setText(f"Failed: {self._progress.failed_chapters}")
+        self._remaining_label.setText(f"Remaining: {self._progress.pending_chapters}")
+
+    def _update_eta(self) -> None:
+        """Update ETA display."""
+        if not self._progress or not self._start_time:
+            return
+
+        if self._progress.total_chapters == 0:
+            return
+
+        completed = self._progress.completed_chapters
+        if completed == 0:
+            self._eta_label.setText("ETA: Calculating...")
+            return
+
+        # Calculate elapsed time
+        elapsed = (datetime.now() - self._start_time).total_seconds()
+
+        # Estimate time per chapter
+        time_per_chapter = elapsed / completed
+        remaining = self._progress.total_chapters - completed
+        eta_seconds = remaining * time_per_chapter
+
+        self._eta_label.setText(f"ETA: {self._format_time(eta_seconds)}")
+
+    def _format_time(self, seconds: float) -> str:
+        """
+        Format time duration as MM:SS or HH:MM:SS.
+
+        Args:
+            seconds: Time in seconds
+
+        Returns:
+            Formatted time string
+        """
+        hours = int(seconds // 3600)
+        minutes = int((seconds % 3600) // 60)
+        secs = int(seconds % 60)
+
+        if hours > 0:
+            return f"{hours:02d}:{minutes:02d}:{secs:02d}"
+        return f"{minutes:02d}:{secs:02d}"
+
+    def reset(self) -> None:
+        """Reset the progress widget to initial state."""
+        self._progress = None
+        self._start_time = None
+        self._chapter_items.clear()
+        self._eta_timer.stop()
+
+        # Clear container
+        while self._chapters_layout.count() > 0:
+            item = self._chapters_layout.takeAt(0)
+            if item.widget():
+                item.widget().deleteLater()
+        self._chapters_layout.addStretch()
+
+        # Reset UI
+        self._stage_label.setText("No active translation")
+        self._stage_label.setStyleSheet("")
+        self._progress_bar.setValue(0)
+        self._completed_label.setText("Completed: 0")
+        self._failed_label.setText("Failed: 0")
+        self._remaining_label.setText("Remaining: 0")
+        self._eta_label.setText("ETA: --:--")
+
+    @property
+    def current_progress(self) -> Optional[PipelineProgress]:
+        """Get the current progress object."""
+        return self._progress

+ 242 - 0
tests/ui/test_file_selector.py

@@ -0,0 +1,242 @@
+"""
+Tests for FileSelector UI component.
+"""
+
+import pytest
+
+# Skip tests if PyQt6 is not installed
+pytest.importorskip("PyQt6")
+
+from pathlib import Path
+from datetime import datetime
+
+from PyQt6.QtWidgets import QApplication
+from PyQt6.QtCore import Qt, QFileSystemWatcher
+from PyQt6.QtTest import QTest
+
+from src.ui.file_selector import FileSelector
+
+
+@pytest.fixture
+def app(qtbot):
+    """Create QApplication fixture."""
+    test_app = QApplication.instance()
+    if test_app is None:
+        test_app = QApplication([])
+    yield test_app
+
+
+@pytest.fixture
+def file_selector(app, qtbot, tmp_path):
+    """Create FileSelector fixture with temp directory."""
+    selector = FileSelector()
+    qtbot.addWidget(selector)
+    yield selector, tmp_path
+    selector.close()
+
+
+class TestFileSelector:
+    """Test FileSelector functionality."""
+
+    def test_initialization(self, file_selector):
+        """Test file selector initializes correctly."""
+        selector, _ = file_selector
+        assert selector.current_path is None
+        assert not selector.is_valid
+        assert selector._path_display.placeholderText() == selector.DEFAULT_PLACEHOLDER
+
+    def test_no_file_selected_initially(self, file_selector):
+        """Test no file is selected initially."""
+        selector, _ = file_selector
+        assert selector.current_path is None
+        assert not selector.is_valid
+        assert selector._name_value.text() == "-"
+        assert selector._size_value.text() == "-"
+        assert selector._lines_value.text() == "-"
+
+    def test_set_valid_file(self, file_selector):
+        """Test setting a valid file."""
+        selector, tmp_path = file_selector
+
+        # Create a test file
+        test_file = tmp_path / "test.txt"
+        test_file.write_text("Line 1\nLine 2\nLine 3\n", encoding='utf-8')
+
+        # Set the file
+        selector.set_file(test_file)
+
+        assert selector.current_path == test_file
+        assert selector.is_valid
+        assert selector._name_value.text() == "test.txt"
+
+    def test_file_info_display(self, file_selector):
+        """Test file information is displayed correctly."""
+        selector, tmp_path = file_selector
+
+        # Create a test file with known content
+        content = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5\n"
+        test_file = tmp_path / "test.txt"
+        test_file.write_text(content, encoding='utf-8')
+
+        selector.set_file(test_file)
+
+        # Check name
+        assert selector._name_value.text() == "test.txt"
+
+        # Check size (should be non-zero)
+        size_text = selector._size_value.text()
+        assert "B" in size_text
+
+        # Check line count
+        lines_text = selector._lines_value.text()
+        assert "5" in lines_text or "5," in lines_text  # 5 lines
+
+    def test_clear_selection(self, file_selector):
+        """Test clearing file selection."""
+        selector, tmp_path = file_selector
+
+        # Create and set a file
+        test_file = tmp_path / "test.txt"
+        test_file.write_text("Content\n", encoding='utf-8')
+        selector.set_file(test_file)
+
+        assert selector.is_valid
+
+        # Clear the selection
+        selector.clear()
+
+        assert selector.current_path is None
+        assert not selector.is_valid
+        assert selector._name_value.text() == "-"
+
+    def test_file_selected_signal(self, file_selector, qtbot):
+        """Test file_selected signal is emitted."""
+        selector, tmp_path = file_selector
+
+        signal_received = []
+
+        def on_file_selected(path):
+            signal_received.append(path)
+
+        selector.file_selected.connect(on_file_selected)
+
+        # Create and set a file
+        test_file = tmp_path / "test.txt"
+        test_file.write_text("Content\n", encoding='utf-8')
+        selector.set_file(test_file)
+
+        assert len(signal_received) == 1
+        assert signal_received[0] == test_file
+
+    def test_selection_cleared_signal(self, file_selector):
+        """Test selection_cleared signal is emitted."""
+        selector, tmp_path = file_selector
+
+        signal_received = []
+
+        def on_cleared():
+            signal_received.append(True)
+
+        selector.selection_cleared.connect(on_cleared)
+
+        # Create and set a file
+        test_file = tmp_path / "test.txt"
+        test_file.write_text("Content\n", encoding='utf-8')
+        selector.set_file(test_file)
+
+        # Clear the selection
+        selector.clear()
+
+        assert len(signal_received) == 1
+
+    def test_size_formatting(self):
+        """Test file size formatting."""
+        selector = FileSelector()
+
+        # Test different size units
+        assert "B" in selector._format_size(512)
+        assert "KB" in selector._format_size(2048)
+        assert "MB" in selector._format_size(1024 * 1024 * 5)
+        assert "GB" in selector._format_size(1024 * 1024 * 1024 * 2)
+
+    def test_line_count_property(self, file_selector):
+        """Test line_count property."""
+        selector, tmp_path = file_selector
+
+        # No file selected
+        assert selector.line_count is None
+
+        # Create a file with 10 lines
+        test_file = tmp_path / "test.txt"
+        test_file.write_text("\n".join([f"Line {i}" for i in range(10)]), encoding='utf-8')
+        selector.set_file(test_file)
+
+        assert selector.line_count == 10
+
+    def test_non_existent_file(self, file_selector):
+        """Test handling of non-existent file."""
+        selector, tmp_path = file_selector
+
+        # Set a non-existent path
+        non_existent = tmp_path / "does_not_exist.txt"
+        selector.set_file(non_existent)
+
+        # Should handle gracefully
+        assert selector.current_path == non_existent
+        assert not selector.is_valid
+
+    def test_empty_file(self, file_selector):
+        """Test handling of empty file."""
+        selector, tmp_path = file_selector
+
+        # Create an empty file
+        test_file = tmp_path / "empty.txt"
+        test_file.write_text("", encoding='utf-8')
+        selector.set_file(test_file)
+
+        assert selector.is_valid
+        assert selector.line_count == 0
+
+    def test_chinese_text_file(self, file_selector):
+        """Test handling of Chinese text file."""
+        selector, tmp_path = file_selector
+
+        # Create a file with Chinese text
+        test_file = tmp_path / "chinese.txt"
+        test_file.write_text("第一章:开始\n第二章:发展\n第三章:结束\n", encoding='utf-8')
+        selector.set_file(test_file)
+
+        assert selector.is_valid
+        assert selector.line_count == 3
+
+
+class TestFileSelectorUI:
+    """Test FileSelector UI elements."""
+
+    def test_browse_button_exists(self, file_selector):
+        """Test browse button exists."""
+        selector, _ = file_selector
+        assert selector._browse_btn is not None
+        assert selector._browse_btn.text() == "Browse..."
+
+    def test_clear_button_initially_disabled(self, file_selector):
+        """Test clear button is initially disabled."""
+        selector, _ = file_selector
+        assert not selector._clear_btn.isEnabled()
+
+    def test_clear_button_enabled_with_file(self, file_selector):
+        """Test clear button is enabled when file is selected."""
+        selector, tmp_path = file_selector
+
+        test_file = tmp_path / "test.txt"
+        test_file.write_text("Content\n", encoding='utf-8')
+        selector.set_file(test_file)
+
+        assert selector._clear_btn.isEnabled()
+
+    def test_groups_exist(self, file_selector):
+        """Test UI groups are created."""
+        selector, _ = file_selector
+        # Check for groups (they should exist)
+        assert selector.layout() is not None
+        assert selector.layout().count() > 0

+ 400 - 0
tests/ui/test_progress_widget.py

@@ -0,0 +1,400 @@
+"""
+Tests for ProgressWidget UI component.
+"""
+
+import pytest
+
+# Skip tests if PyQt6 is not installed
+pytest.importorskip("PyQt6")
+
+from datetime import datetime, timedelta
+from unittest.mock import Mock
+
+from PyQt6.QtWidgets import QApplication
+from PyQt6.QtCore import Qt, QTimer
+from PyQt6.QtTest import QTest
+
+from src.ui.progress_widget import ProgressWidget, ChapterProgressItem
+from src.scheduler.progress import ProgressNotifier
+from src.scheduler.models import ChapterTask, PipelineProgress, TaskStatus, SchedulerState
+
+
+@pytest.fixture
+def app(qtbot):
+    """Create QApplication fixture."""
+    test_app = QApplication.instance()
+    if test_app is None:
+        test_app = QApplication([])
+    yield test_app
+
+
+@pytest.fixture
+def progress_widget(app, qtbot):
+    """Create ProgressWidget fixture."""
+    widget = ProgressWidget()
+    qtbot.addWidget(widget)
+    yield widget
+    widget.close()
+
+
+@pytest.fixture
+def sample_chapter_tasks():
+    """Create sample chapter tasks for testing."""
+    return [
+        ChapterTask(
+            chapter_id=f"ch_{i}",
+            chapter_index=i,
+            title=f"Chapter {i + 1}",
+            original_content=f"Content for chapter {i + 1}\n",
+        )
+        for i in range(5)
+    ]
+
+
+class TestProgressWidget:
+    """Test ProgressWidget functionality."""
+
+    def test_initialization(self, progress_widget):
+        """Test progress widget initializes correctly."""
+        assert progress_widget._progress is None
+        assert progress_widget._start_time is None
+        assert len(progress_widget._chapter_items) == 0
+        assert not progress_widget._eta_timer.isActive()
+
+    def test_initial_ui_state(self, progress_widget):
+        """Test initial UI state."""
+        assert "No active translation" in progress_widget._stage_label.text()
+        assert progress_widget._progress_bar.value() == 0
+        assert progress_widget._completed_label.text() == "Completed: 0"
+        assert progress_widget._failed_label.text() == "Failed: 0"
+        assert progress_widget._remaining_label.text() == "Remaining: 0"
+
+    def test_connect_to_notifier(self, progress_widget):
+        """Test connecting to ProgressNotifier."""
+        notifier = ProgressNotifier()
+        progress_widget.connect_to_notifier(notifier)
+
+        assert notifier.observer_count == 1
+
+    def test_disconnect_from_notifier(self, progress_widget):
+        """Test disconnecting from ProgressNotifier."""
+        notifier = ProgressNotifier()
+        progress_widget.connect_to_notifier(notifier)
+        assert notifier.observer_count == 1
+
+        progress_widget.disconnect_from_notifier(notifier)
+        assert notifier.observer_count == 0
+
+    def test_on_pipeline_start(self, progress_widget, sample_chapter_tasks):
+        """Test pipeline start event."""
+        notifier = ProgressNotifier()
+        progress_widget.connect_to_notifier(notifier)
+
+        notifier.notify_pipeline_start(5)
+
+        assert progress_widget._progress is not None
+        assert progress_widget._progress.total_chapters == 5
+        assert progress_widget._progress.state == SchedulerState.RUNNING
+        assert progress_widget._start_time is not None
+        assert progress_widget._eta_timer.isActive()
+
+    def test_on_pipeline_complete(self, progress_widget):
+        """Test pipeline complete event."""
+        notifier = ProgressNotifier()
+        progress_widget.connect_to_notifier(notifier)
+
+        # Start the pipeline
+        notifier.notify_pipeline_start(5)
+
+        # Complete the pipeline
+        progress = PipelineProgress(
+            total_chapters=5,
+            completed_chapters=5,
+            state=SchedulerState.COMPLETED
+        )
+        notifier.notify_pipeline_complete(progress)
+
+        assert not progress_widget._eta_timer.isActive()
+        assert progress_widget._progress_bar.value() == 100
+
+    def test_on_pipeline_paused(self, progress_widget):
+        """Test pipeline paused event."""
+        notifier = ProgressNotifier()
+        progress_widget.connect_to_notifier(notifier)
+
+        progress = PipelineProgress(
+            total_chapters=5,
+            completed_chapters=2,
+            current_chapter=2,
+            state=SchedulerState.PAUSED
+        )
+        notifier.notify_pipeline_paused(progress)
+
+        assert "paused" in progress_widget._stage_label.text().lower()
+
+    def test_on_pipeline_resumed(self, progress_widget):
+        """Test pipeline resumed event."""
+        notifier = ProgressNotifier()
+        progress_widget.connect_to_notifier(notifier)
+
+        progress = PipelineProgress(
+            total_chapters=5,
+            completed_chapters=2,
+            current_chapter=2,
+            state=SchedulerState.RUNNING
+        )
+        notifier.notify_pipeline_resumed(progress)
+
+        assert progress_widget._eta_timer.isActive()
+
+    def test_on_chapter_start(self, progress_widget, sample_chapter_tasks):
+        """Test chapter start event."""
+        notifier = ProgressNotifier()
+        progress_widget.connect_to_notifier(notifier)
+
+        notifier.notify_pipeline_start(5)
+        notifier.notify_chapter_start(sample_chapter_tasks[0])
+
+        assert len(progress_widget._chapter_items) == 1
+
+    def test_on_chapter_complete(self, progress_widget, sample_chapter_tasks):
+        """Test chapter complete event."""
+        notifier = ProgressNotifier()
+        progress_widget.connect_to_notifier(notifier)
+
+        notifier.notify_pipeline_start(5)
+
+        # Mark task as completed
+        task = sample_chapter_tasks[0]
+        task.status = TaskStatus.COMPLETED
+        notifier.notify_chapter_complete(task)
+
+        assert progress_widget._progress.completed_chapters == 1
+
+    def test_on_chapter_failed(self, progress_widget, sample_chapter_tasks):
+        """Test chapter failed event."""
+        notifier = ProgressNotifier()
+        progress_widget.connect_to_notifier(notifier)
+
+        notifier.notify_pipeline_start(5)
+
+        # Mark task as failed
+        task = sample_chapter_tasks[0]
+        task.status = TaskStatus.FAILED
+        task.error_message = "Test error"
+        notifier.notify_chapter_failed(task, "Test error")
+
+        assert progress_widget._progress.failed_chapters == 1
+
+    def test_multiple_chapters(self, progress_widget, sample_chapter_tasks):
+        """Test handling multiple chapters."""
+        notifier = ProgressNotifier()
+        progress_widget.connect_to_notifier(notifier)
+
+        notifier.notify_pipeline_start(5)
+
+        # Start and complete 3 chapters
+        for i in range(3):
+            task = sample_chapter_tasks[i]
+            notifier.notify_chapter_start(task)
+            task.status = TaskStatus.COMPLETED
+            notifier.notify_chapter_complete(task)
+
+        assert len(progress_widget._chapter_items) == 3
+        assert progress_widget._progress.completed_chapters == 3
+
+    def test_stats_update(self, progress_widget):
+        """Test statistics display updates."""
+        notifier = ProgressNotifier()
+        progress_widget.connect_to_notifier(notifier)
+
+        notifier.notify_pipeline_start(10)
+
+        progress = PipelineProgress(
+            total_chapters=10,
+            completed_chapters=5,
+            failed_chapters=1,
+            state=SchedulerState.RUNNING
+        )
+        progress_widget._progress = progress
+        progress_widget._update_stats()
+
+        assert progress_widget._completed_label.text() == "Completed: 5"
+        assert progress_widget._failed_label.text() == "Failed: 1"
+        assert progress_widget._remaining_label.text() == "Remaining: 4"
+
+    def test_reset(self, progress_widget):
+        """Test resetting the widget."""
+        notifier = ProgressNotifier()
+        progress_widget.connect_to_notifier(notifier)
+
+        # Start and add some chapters
+        notifier.notify_pipeline_start(5)
+
+        task = ChapterTask(
+            chapter_id="ch_0",
+            chapter_index=0,
+            title="Chapter 1",
+            original_content="Content\n"
+        )
+        notifier.notify_chapter_start(task)
+
+        assert len(progress_widget._chapter_items) == 1
+
+        # Reset
+        progress_widget.reset()
+
+        assert progress_widget._progress is None
+        assert progress_widget._start_time is None
+        assert len(progress_widget._chapter_items) == 0
+        assert not progress_widget._eta_timer.isActive()
+
+    def test_format_time(self, progress_widget):
+        """Test time formatting."""
+        # Seconds only
+        assert progress_widget._format_time(45) == "00:45"
+
+        # Minutes and seconds
+        assert progress_widget._format_time(125) == "02:05"
+
+        # Hours, minutes, seconds
+        assert progress_widget._format_time(3661) == "01:01:01"
+
+
+class TestChapterProgressItem:
+    """Test ChapterProgressItem functionality."""
+
+    def test_initialization(self, app):
+        """Test chapter progress item initialization."""
+        task = ChapterTask(
+            chapter_id="ch_0",
+            chapter_index=0,
+            title="Chapter 1",
+            original_content="Content\n"
+        )
+
+        item = ChapterProgressItem(task)
+
+        assert item._task == task
+        assert item._title_label.text() == "Chapter 1: Chapter 1"
+
+    def test_status_icons(self, app):
+        """Test status icons are correct."""
+        task = ChapterTask(
+            chapter_id="ch_0",
+            chapter_index=0,
+            title="Chapter 1",
+            original_content="Content\n"
+        )
+
+        item = ChapterProgressItem(task)
+
+        # Test different statuses
+        for status, expected_icon in ChapterProgressItem.STATUS_ICONS.items():
+            task.status = status
+            item._update_style()
+            assert item._icon_label.text() == expected_icon
+
+    def test_update_task(self, app):
+        """Test updating task in item."""
+        task = ChapterTask(
+            chapter_id="ch_0",
+            chapter_index=0,
+            title="Chapter 1",
+            original_content="Content\n"
+        )
+
+        item = ChapterProgressItem(task)
+
+        # Update task with completion
+        task.status = TaskStatus.COMPLETED
+        task.started_at = datetime.now()
+        task.completed_at = datetime.now() + timedelta(seconds=10)
+
+        item.update_task(task)
+
+        assert item._task.status == TaskStatus.COMPLETED
+        assert "Completed" in item._details_label.text()
+
+    def test_failed_task_display(self, app):
+        """Test failed task displays error message."""
+        task = ChapterTask(
+            chapter_id="ch_0",
+            chapter_index=0,
+            title="Chapter 1",
+            original_content="Content\n",
+            status=TaskStatus.FAILED,
+            error_message="Translation failed: connection timeout"
+        )
+
+        item = ChapterProgressItem(task)
+
+        assert "Failed" in item._details_label.text()
+        assert "connection timeout" in item._details_label.text()
+
+    def test_retrying_task_display(self, app):
+        """Test retrying task displays retry count."""
+        task = ChapterTask(
+            chapter_id="ch_0",
+            chapter_index=0,
+            title="Chapter 1",
+            original_content="Content\n",
+            status=TaskStatus.RETRYING,
+            retry_count=2
+        )
+
+        item = ChapterProgressItem(task)
+
+        assert "Retrying" in item._details_label.text()
+        assert "2/3" in item._details_label.text()
+
+
+class TestProgressIntegration:
+    """Test ProgressWidget integration with ProgressNotifier."""
+
+    def test_full_workflow(self, progress_widget, sample_chapter_tasks):
+        """Test complete translation workflow."""
+        notifier = ProgressNotifier()
+        progress_widget.connect_to_notifier(notifier)
+
+        # Start pipeline
+        notifier.notify_pipeline_start(5)
+        assert progress_widget._progress.total_chapters == 5
+
+        # Process chapters
+        for i, task in enumerate(sample_chapter_tasks):
+            notifier.notify_chapter_start(task)
+
+            # Simulate completion
+            task.status = TaskStatus.COMPLETED
+            task.started_at = datetime.now()
+            task.completed_at = datetime.now() + timedelta(seconds=10)
+            notifier.notify_chapter_complete(task)
+
+        # Complete pipeline
+        progress = progress_widget._progress
+        notifier.notify_pipeline_complete(progress)
+
+        assert progress_widget._progress_bar.value() == 100
+
+    def test_error_recovery(self, progress_widget, sample_chapter_tasks):
+        """Test error and recovery scenario."""
+        notifier = ProgressNotifier()
+        progress_widget.connect_to_notifier(notifier)
+
+        # Start
+        notifier.notify_pipeline_start(3)
+
+        # First chapter succeeds
+        notifier.notify_chapter_start(sample_chapter_tasks[0])
+        sample_chapter_tasks[0].status = TaskStatus.COMPLETED
+        notifier.notify_chapter_complete(sample_chapter_tasks[0])
+
+        # Second chapter fails
+        notifier.notify_chapter_start(sample_chapter_tasks[1])
+        sample_chapter_tasks[1].status = TaskStatus.FAILED
+        sample_chapter_tasks[1].error_message = "Network error"
+        notifier.notify_chapter_failed(sample_chapter_tasks[1], "Network error")
+
+        assert progress_widget._progress.completed_chapters == 1
+        assert progress_widget._progress.failed_chapters == 1