diff --git a/.DS_Store b/.DS_Store
index 88788dd..0ec6926 100644
Binary files a/.DS_Store and b/.DS_Store differ
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 33be1b5..f1342d9 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -30,16 +30,16 @@ jobs:
if: github.event_name == 'push'
uses: softprops/action-gh-release@v2
with:
- tag_name: 2.0.0
- name: 2.0.0 Stable
+ tag_name: 2.1.0A6
+ name: 2.1.0 Alpha 4
body: ${{ github.event.head_commit.message }}
- prerelease: false
+ prerelease: true
files: |
./dist/Converter_arm64_darwin.zip
build_intel:
name: Build Intel
runs-on: macos-15-intel
- if: github.repository_owner == 'pyquick'
+ if: github.repository_owner == 'intsant'
permissions:
contents: write
steps:
@@ -58,10 +58,9 @@ jobs:
if: github.event_name == 'push'
uses: softprops/action-gh-release@v2
with:
- tag_name: 2.0.0
- name: 2.0.0 Stable
+ tag_name: 2.1.0A6
+ name: 2.1.0 Alpha 4
body: ${{ github.event.head_commit.message }}
- prerelease: false
+ prerelease: true
files: |
- ./dist/Converter_intel_darwin.zip
-
\ No newline at end of file
+ ./dist/Converter_intel_darwin.zip
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 52c80aa..572e1d7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -201,7 +201,7 @@ cython_debug/
# refer to https://docs.cursor.com/context/ignore-files
.cursorignore
.cursorindexingignore
-
+.trae/
# Marimo
marimo/_static/
marimo/_lsp/
diff --git a/CHANGELOG/v2.0.0_new_features.md b/CHANGELOG/v2.0.0_new_features.md
deleted file mode 100644
index 4ff6c81..0000000
--- a/CHANGELOG/v2.0.0_new_features.md
+++ /dev/null
@@ -1,77 +0,0 @@
-# New Features and Code Improvements
-
-## New Features
-
-### 1. Password Protection Support
-- **Password Detection Module**: Implemented password detection functionality for ZIP, RAR, and 7z formats
-- **Secure Password Dialog**: Added secure password input interface with comprehensive error handling
-- **Protected File Handling**: Enhanced capability for processing password-protected archive files
-
-### 2. Update Management System
-- **GitHub PAT Integration**: Implemented secure storage and validation of GitHub Personal Access Tokens (PAT)
-- **Automatic Update Mechanism**: Added automatic update detection, download, and installation workflow
-- **Backup and Recovery**: Implemented seamless backup and recovery functionality during update processes
-
-### 3. Theme System
-- **Automatic Theme Switching**: Implemented system theme detection and synchronization
-- **Dark/Light Mode Support**: Added complete dark and light theme support
-- **Theme Persistence**: User theme selections persist across application restarts
-
-### 4. Settings Management System
-- **Real-time Auto-save**: Implemented real-time automatic saving for settings dialogs
-- **Status Label Display**: Added visual feedback for save status
-- **Settings Validation**: Implemented validation for configuration items
-
-### 5. Archive Management Tools
-- **ZIP Processing Tools**: Added complete ZIP file creation, extraction, and modification functionality
-- **Progress Tracking**: Added detailed progress display for archive operations
-- **Content Listing**: Implemented archive file content viewing functionality
-
-## Code Improvements
-
-### 1. Architecture Refactoring
-- **Modular Design**: Refactored code into a more modular architecture for improved maintainability
-- **Component Separation**: Achieved clear separation between UI components and business logic
-- **Dependency Injection**: Improved dependency relationship management between components
-
-### 2. UI Framework Migration
-- **wxPython to PySide6 Migration**: Completed full migration from wxPython to PySide6 (Qt6)
-- **Modernized UI**: Implemented a more modern, fluid user interface design
-- **Responsive Layout**: Improved interface layout responsiveness and adaptability
-
-### 3. Error Handling and Logging
-- **Comprehensive Error Handling**: Implemented complete exception catching and handling mechanisms
-- **Structured Logging System**: Added structured logging system
-- **User-Friendly Error Messages**: Improved user-friendliness of error messages
-
-### 4. Performance Optimization
-- **Asynchronous Operations**: Converted time-consuming operations to asynchronous execution for better UI responsiveness
-- **Memory Management**: Optimized memory usage and reduced memory leak risks
-- **Resource Loading**: Improved resource loading and management methods
-
-### 5. Security Enhancements
-- **Secure Storage**: Implemented secure credential storage using macOS Keychain
-- **Input Validation**: Added comprehensive input validation and sanitization mechanisms
-- **Permission Management**: Improved application permission management and requests
-
-### 6. Build System Improvements
-- **Dual Architecture Support**: Implemented dual architecture builds for ARM64 and Intel Macs
-- **Dependency Management**: Improved dependency management and version control during build processes
-- **Automated CI/CD**: Enhanced GitHub Actions workflow automation
-
-### 7. Code Quality Enhancement
-- **Type Hints**: Added comprehensive Python type hints
-- **Docstrings**: Improved function and class documentation strings
-- **Code Standards**: Unified code style and naming conventions
-
-## Summary
-
-These new features and code improvements collectively constitute a comprehensive upgrade from version 1.0.0 pre2 to the current 2.0.0 version, not only enhancing application functionality but also significantly improving code quality and user experience.
-
-The most significant changes include:
-- Complete UI framework migration from wxPython to PySide6 (Qt6)
-- Implementation of password protection and secure credential management
-- Addition of automatic update management system
-- Enhanced theme system with automatic switching
-- Improved build system supporting dual architecture (ARM64 and Intel)
-- Comprehensive code refactoring for better maintainability and performance
\ No newline at end of file
diff --git a/Converter.py b/Converter.py
index 7e5a356..bcf801e 100644
--- a/Converter.py
+++ b/Converter.py
@@ -3,8 +3,8 @@
from concurrent.futures import thread
from importlib import reload
import sys
-
import os
+import threading
from PySide6.QtWidgets import (
QApplication,
QWidget,
@@ -16,14 +16,26 @@
QGridLayout,
QSizePolicy,
QGroupBox,
- QDialog
+ QDialog,
+ QListWidget,
+ QListWidgetItem,
+ QMessageBox,
+ QFrame,
+ QStackedWidget
)
-from PySide6.QtGui import QIcon, QPainter, QPixmap, QPalette
-from PySide6.QtCore import QSize, Qt, QSettings, QPropertyAnimation, QEasingCurve, QTimer
+from PySide6.QtGui import QIcon, QPainter, QPixmap, QPalette, QColor
+from PySide6.QtCore import QSize, Qt, QSettings, QPropertyAnimation, QEasingCurve, QTimer, Signal
import multiprocessing
-from qfluentwidgets import Theme, setTheme,qconfig,SystemThemeListener
- # Keep for freeze_support, but remove direct Process usage
-from settings.update_settings_gui import UpdateDialog
+from qfluentwidgets import (
+ HeaderCardWidget, ImageLabel, Theme, setTheme, qconfig, SystemThemeListener,
+ FluentWindow, NavigationItemPosition,
+ CardWidget, PushButton, PrimaryPushButton, IconWidget,
+ BodyLabel, CaptionLabel, SubtitleLabel, TitleLabel, LargeTitleLabel,
+ FluentIcon as FIF, setFont, TransparentToolButton, SegmentedWidget,
+ setCustomStyleSheet, ElevatedCardWidget, ProgressBar, FlowLayout,
+ ScrollArea
+)
+from qfluentwidgets.components.widgets.card_widget import SimpleCardWidget
from settings.settings_gui import SettingsDialog
from con import CON # Import CON instance for theme settings
# Encoding settings have been moved to debug_logger for handling
@@ -55,7 +67,518 @@ def create_placeholder_icon(path: str, color: str, text: str):
return True
return False
-class IconButtonsWindow(QWidget):
+
+class AppCard(CardWidget):
+ """Application card widget"""
+
+ def __init__(self, icon_path, title, content, app_type, parent=None):
+ super().__init__(parent)
+ self.title = title
+ self.content = content
+ self.app_type = app_type
+ self.icon_path = icon_path
+ self.icon_widget = ImageLabel(icon_path, self)
+ self.title_label = BodyLabel(self.title, self)
+ self.content_label = CaptionLabel(self.content, self)
+ self.icon_widget.scaledToHeight(68)
+ self.icon_widget.setFixedSize(48, 48)
+ self.content_label.setTextColor(QColor("#606060"), QColor("#d2d2d2"))
+ self.setFixedHeight(73)
+ self.h_box_layout = QHBoxLayout(self)
+ self.v_box_layout = QVBoxLayout()
+
+
+ # Configure layouts
+ self.h_box_layout.setContentsMargins(20, 11, 11, 11)
+ self.h_box_layout.setSpacing(15)
+ self.v_box_layout.setContentsMargins(0, 0, 0, 0)
+ self.v_box_layout.setSpacing(0)
+
+
+ self.open_button = PrimaryPushButton('Open', self)
+ self.open_button.setFixedWidth(120)
+ self.more_button = TransparentToolButton(FIF.MORE, self)
+ self.more_button.setFixedSize(32, 32)
+
+ # Add components to layouts
+ self.h_box_layout.addWidget(self.icon_widget)
+
+ self.v_box_layout.addWidget(self.title_label, 0, Qt.AlignmentFlag.AlignVCenter)
+ self.v_box_layout.addWidget(self.content_label, 0, Qt.AlignmentFlag.AlignVCenter)
+ self.h_box_layout.addLayout(self.v_box_layout)
+
+ self.h_box_layout.addStretch(1)
+ self.h_box_layout.addWidget(self.open_button, 0, Qt.AlignmentFlag.AlignRight)
+ self.h_box_layout.addWidget(self.more_button, 0, Qt.AlignmentFlag.AlignRight)
+
+ self.open_button.clicked.connect(self.on_open_clicked)
+
+
+ def on_open_clicked(self):
+ """Handle open button clicked event"""
+ if self.app_type == 'image':
+ run_image_app()
+ elif self.app_type == 'arc':
+ run_zip_app()
+
+
+class AppCardTask(CardWidget):
+ """Task card widget for task manager interface"""
+
+ task_cancelled = Signal(str)
+ task_retried = Signal(str)
+ task_removed = Signal(str)
+
+ def __init__(self, task_id, task_type, task_info, parent=None):
+ super().__init__(parent)
+ self.task_id = task_id
+ self.task_type = task_type
+ self.task_info = task_info
+ self.setup_ui()
+ self.setup_connections()
+
+ def setup_ui(self):
+ """Initialize UI components"""
+ self.setFixedHeight(120)
+ self.setFixedWidth(280)
+ self.setBorderRadius(12)
+
+ main_layout = QHBoxLayout(self)
+ main_layout.setContentsMargins(15, 12, 15, 12)
+ main_layout.setSpacing(12)
+
+ icon_layout = QVBoxLayout()
+ icon_layout.setContentsMargins(0, 0, 0, 0)
+ icon_layout.setSpacing(4)
+
+ if self.task_type == "image":
+ task_icon = FIF.PHOTO
+ else:
+ task_icon = FIF.FOLDER
+ self.icon_widget = IconWidget(task_icon, self)
+ self.icon_widget.setFixedSize(40, 40)
+ icon_layout.addWidget(self.icon_widget, 0, Qt.AlignmentFlag.AlignCenter)
+
+ status_badges = {
+ "pending": ("Pending", "#9e9e9e"),
+ "running": ("Running", "#2196f3"),
+ "completed": ("Completed", "#4caf50"),
+ "failed": ("Failed", "#f44336"),
+ "cancelled": ("Cancelled", "#ff9800")
+ }
+ status = self.task_info.get("status", "pending")
+ status_text, status_color = status_badges.get(status, status_badges["pending"])
+
+ self.status_badge = BodyLabel(status_text)
+ self.status_badge.setStyleSheet(f"""
+ BodyLabel {{
+ background-color: {status_color}20;
+ color: {status_color};
+ padding: 2px 8px;
+ border-radius: 10px;
+ font-size: 10px;
+ font-weight: bold;
+ }}
+ """)
+ icon_layout.addWidget(self.status_badge, 0, Qt.AlignmentFlag.AlignCenter)
+
+ main_layout.addLayout(icon_layout)
+
+ content_layout = QVBoxLayout()
+ content_layout.setContentsMargins(0, 0, 0, 0)
+ content_layout.setSpacing(6)
+
+ input_path = self.task_info.get("input_path", "Unknown")
+ filename = os.path.basename(input_path) if input_path else "Unknown"
+ self.title_label = BodyLabel(filename)
+ self.title_label.setTextColor(QColor("#333333"), QColor("#e0e0e0"))
+ self.title_label.setFixedWidth(160)
+ content_layout.addWidget(self.title_label)
+
+ self.status_label = CaptionLabel(self.get_status_text())
+ self.status_label.setTextColor(QColor("#666666"), QColor("#a0a0a0"))
+ self.status_label.setFixedWidth(160)
+ content_layout.addWidget(self.status_label)
+
+ self.progress_bar = ProgressBar()
+ self.progress_bar.setFixedHeight(4)
+ self.progress_bar.setFixedWidth(160)
+ self.progress_bar.setValue(self.task_info.get("progress", 0))
+ content_layout.addWidget(self.progress_bar)
+
+ main_layout.addLayout(content_layout)
+
+ button_layout = QVBoxLayout()
+ button_layout.setContentsMargins(0, 0, 0, 0)
+ button_layout.setSpacing(8)
+ button_layout.addStretch(1)
+
+ self.cancel_button = TransparentToolButton(FIF.CANCEL, self)
+ self.cancel_button.setFixedSize(28, 28)
+ self.cancel_button.setToolTip("Cancel Task")
+ button_layout.addWidget(self.cancel_button, 0, Qt.AlignmentFlag.AlignRight)
+
+ self.retry_button = TransparentToolButton(FIF.RETURN, self)
+ self.retry_button.setFixedSize(28, 28)
+ self.retry_button.setToolTip("Retry Task")
+ self.retry_button.setVisible(False)
+ button_layout.addWidget(self.retry_button, 0, Qt.AlignmentFlag.AlignRight)
+
+ self.remove_button = TransparentToolButton(FIF.DELETE, self)
+ self.remove_button.setFixedSize(28, 28)
+ self.remove_button.setToolTip("Remove Task")
+ button_layout.addWidget(self.remove_button, 0, Qt.AlignmentFlag.AlignRight)
+
+ button_layout.addStretch(1)
+
+ main_layout.addLayout(button_layout)
+
+ def get_status_text(self):
+ """Get status display text"""
+ status = self.task_info.get("status", "pending")
+ if status == "pending":
+ return "Waiting in queue..."
+ elif status == "running":
+ progress = self.task_info.get("progress", 0)
+ return f"Processing... {progress}%"
+ elif status == "completed":
+ return "Task completed successfully"
+ elif status == "failed":
+ error = self.task_info.get("error", "Unknown error")
+ return f"Failed: {error}"
+ elif status == "cancelled":
+ return "Task was cancelled"
+ return "Unknown status"
+
+ def setup_connections(self):
+ """Setup button connections"""
+ self.cancel_button.clicked.connect(lambda: self.task_cancelled.emit(self.task_id))
+ self.retry_button.clicked.connect(lambda: self.task_retried.emit(self.task_id))
+ self.remove_button.clicked.connect(lambda: self.task_removed.emit(self.task_id))
+
+ def update_task(self, task_info):
+ """Update task information and UI"""
+ self.task_info = task_info
+
+ status_badges = {
+ "pending": ("Pending", "#9e9e9e"),
+ "running": ("Running", "#2196f3"),
+ "completed": ("Completed", "#4caf50"),
+ "failed": ("Failed", "#f44336"),
+ "cancelled": ("Cancelled", "#ff9800")
+ }
+ status = task_info.get("status", "pending")
+ status_text, status_color = status_badges.get(status, status_badges["pending"])
+
+ self.status_badge.setText(status_text)
+ self.status_badge.setStyleSheet(f"""
+ BodyLabel {{
+ background-color: {status_color}20;
+ color: {status_color};
+ padding: 2px 8px;
+ border-radius: 10px;
+ font-size: 10px;
+ font-weight: bold;
+ }}
+ """)
+
+ self.status_label.setText(self.get_status_text())
+ progress = task_info.get("progress", 0)
+ self.progress_bar.setValue(progress)
+
+ if status == "running":
+ self.cancel_button.setVisible(True)
+ self.retry_button.setVisible(False)
+ elif status in ["failed", "cancelled"]:
+ self.cancel_button.setVisible(False)
+ self.retry_button.setVisible(True)
+ else:
+ self.cancel_button.setVisible(False)
+ self.retry_button.setVisible(False)
+
+
+class HomeInterface(QFrame):
+ """Home interface showing app cards"""
+
+ def __init__(self, icon_paths, parent=None):
+ super().__init__(parent)
+ self.icon_paths = icon_paths
+ self.setObjectName("home_interface")
+ self.init_ui()
+
+ def init_ui(self):
+ """Initialize UI components"""
+ # Layouts
+ main_layout = QVBoxLayout(self)
+ main_layout.setContentsMargins(40, 35, 40, 35)
+ main_layout.setSpacing(25)
+
+ # Title
+ title_label = LargeTitleLabel("Converter")
+ title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ main_layout.addWidget(title_label)
+
+ # Image Converter card
+ image_card = AppCard(
+ icon_path=self.icon_paths['app_icon_path'],
+ title="Image Converter",
+ content="Convert PNG images to ICNS format for macOS applications",
+ app_type="image"
+ )
+ image_card.setBorderRadius(35)
+ main_layout.addWidget(image_card)
+
+ # Archive Converter card
+ archive_card = AppCard(
+ icon_path=self.icon_paths['zip_icon_path'],
+ title="Archive Converter",
+ content="Create and extract ZIP, RAR, and 7Z archive files",
+ app_type="arc"
+ )
+ main_layout.addWidget(archive_card)
+ archive_card.setBorderRadius(35)
+
+ # Add stretch to push content to top
+ main_layout.addStretch(1)
+
+
+class TaskInterface(ScrollArea):
+ """Task management interface"""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setObjectName("task_interface")
+ self.setWidgetResizable(True)
+ self.task_cards = {}
+ self.init_ui()
+
+ def init_ui(self):
+ """Initialize UI components"""
+ from qfluentwidgets import setCustomStyleSheet
+
+ container_widget = QWidget()
+ container_widget.setObjectName("task_container")
+ self.setWidget(container_widget)
+
+ main_layout = QVBoxLayout(container_widget)
+ main_layout.setContentsMargins(20, 20, 20, 20)
+ main_layout.setSpacing(20)
+
+ title_label = SubtitleLabel("Task Manager")
+ main_layout.addWidget(title_label)
+
+ self.task_container = QWidget()
+ self.task_flow_layout = FlowLayout(self.task_container)
+ self.task_flow_layout.setContentsMargins(10, 10, 10, 10)
+ self.task_flow_layout.setSpacing(15)
+
+ main_layout.addWidget(self.task_container, 1)
+
+ controls_layout = QHBoxLayout()
+
+ self.clear_tasks_button = PrimaryPushButton("Clear Completed")
+ self.clear_tasks_button.setObjectName("clear_tasks_button")
+ self.clear_tasks_button.setFixedHeight(40)
+ controls_layout.addWidget(self.clear_tasks_button)
+
+ controls_layout.addStretch(1)
+
+ main_layout.addLayout(controls_layout)
+
+ task_count_label = CaptionLabel("Tasks will appear here when you start conversions")
+ task_count_label.setTextColor(QColor("#888888"), QColor("#666666"))
+ main_layout.addWidget(task_count_label, 0, Qt.AlignmentFlag.AlignCenter)
+
+ def add_task_card(self, task_id, task_type, task_info):
+ """Add a new task card to the interface"""
+ if task_id in self.task_cards:
+ return
+
+ task_card = AppCardTask(task_id, task_type, task_info, self.task_container)
+ self.task_cards[task_id] = task_card
+ self.task_flow_layout.addWidget(task_card)
+
+ task_card.task_cancelled.connect(self._on_task_cancelled)
+ task_card.task_retried.connect(self._on_task_retried)
+ task_card.task_removed.connect(self._on_task_removed)
+
+ def update_task_card(self, task_id, task_info):
+ """Update an existing task card"""
+ if task_id in self.task_cards:
+ self.task_cards[task_id].update_task(task_info)
+
+ def remove_task_card(self, task_id):
+ """Remove a task card from the interface"""
+ if task_id in self.task_cards:
+ task_card = self.task_cards.pop(task_id)
+ self.task_flow_layout.removeWidget(task_card)
+ task_card.deleteLater()
+
+ def clear_all_cards(self):
+ """Clear all task cards"""
+ for task_id in list(self.task_cards.keys()):
+ self.remove_task_card(task_id)
+
+ def _on_task_cancelled(self, task_id):
+ """Handle task cancellation"""
+ if hasattr(self.parent(), 'task_manager'):
+ self.parent().task_manager.cancel_task(task_id)
+
+ def _on_task_retried(self, task_id):
+ """Handle task retry"""
+ if hasattr(self.parent(), 'task_manager'):
+ self.parent().task_manager.retry_task(task_id)
+
+ def _on_task_removed(self, task_id):
+ """Handle task removal"""
+ self.remove_task_card(task_id)
+
+
+class SettingsInterface(QFrame):
+ """Settings interface"""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setObjectName("settings_interface")
+ self.init_ui()
+ self.load_settings()
+ self._connect_settings_signals()
+
+ def init_ui(self):
+ """Initialize UI components"""
+ # Layouts
+ main_layout = QVBoxLayout(self)
+ main_layout.setContentsMargins(20, 20, 20, 20)
+ main_layout.setSpacing(15)
+
+ # Create SegmentedWidget and QStackedWidget
+ self.segmented_widget = SegmentedWidget(self)
+ setCustomStyleSheet(self.segmented_widget, CON.qss_seg, CON.qss_seg)
+ self.stacked_widget = QStackedWidget(self)
+
+ # General page
+ general_page = QWidget()
+ general_layout = QVBoxLayout(general_page)
+ general_layout.setContentsMargins(15, 15, 15, 15)
+ general_layout.setSpacing(15)
+
+ from settings.general_settings import GeneralSettingsWidget
+ self.general_widget = GeneralSettingsWidget()
+ self.general_widget.setObjectName("general_widget")
+ general_layout.addWidget(self.general_widget)
+ general_layout.addStretch()
+
+ self.stacked_widget.addWidget(general_page)
+
+ # Debug page
+ debug_page = QWidget()
+ debug_layout = QVBoxLayout(debug_page)
+ debug_layout.setContentsMargins(15, 15, 15, 15)
+ debug_layout.setSpacing(15)
+
+ from debug.debug_gui import DebugSettingsWidget
+ self.debug_widget = DebugSettingsWidget()
+ self.debug_widget.setObjectName("debug_widget")
+ debug_layout.addWidget(self.debug_widget)
+ debug_layout.addStretch()
+
+ self.stacked_widget.addWidget(debug_page)
+
+ # Update page
+ update_page = QWidget()
+ update_layout = QVBoxLayout(update_page)
+ update_layout.setContentsMargins(15, 15, 15, 15)
+ update_layout.setSpacing(15)
+
+ from settings.update_settings_gui import UpdateSettingsWidget
+ self.update_group = UpdateSettingsWidget()
+ self.update_group.setObjectName("update_group")
+ update_layout.addWidget(self.update_group)
+ update_layout.addStretch()
+
+ self.stacked_widget.addWidget(update_page)
+
+ # Add tab items
+ self.add_sub_interface(general_page, "general_page", "General")
+ self.add_sub_interface(debug_page, "debug_page", "Debug")
+ self.add_sub_interface(update_page, "update_page", "Update")
+
+ # Connect tab change signal
+ self.stacked_widget.currentChanged.connect(self.on_current_index_changed)
+ self.stacked_widget.setCurrentIndex(0)
+ self.segmented_widget.setCurrentItem("general_page")
+
+ # Add to main layout
+ main_layout.addWidget(self.segmented_widget, 0, Qt.AlignmentFlag.AlignHCenter)
+ main_layout.addWidget(self.stacked_widget, 1)
+
+ def add_sub_interface(self, widget: QWidget, object_name: str, text: str):
+ """Add sub-page to SegmentedWidget and QStackedWidget"""
+ widget.setObjectName(object_name)
+ self.segmented_widget.addItem(
+ routeKey=object_name,
+ text=text,
+ onClick=lambda: self.stacked_widget.setCurrentWidget(widget)
+ )
+
+ def on_current_index_changed(self, index):
+ """Handle current page change"""
+ widget = self.stacked_widget.widget(index)
+ if widget:
+ self.segmented_widget.setCurrentItem(widget.objectName())
+
+ def load_settings(self):
+ """Load settings from QSettings"""
+ settings = QSettings("MyCompany", "ConverterApp")
+ # Theme settings - always set to System Default (index 0)
+ settings.setValue("theme", 0) # Force save System Default
+
+ # Load General settings (includes Image Converter settings)
+ if hasattr(self, 'general_widget'):
+ self.general_widget.load_settings()
+
+ # Debug settings are now handled by the DebugSettingsWidget itself
+
+ def _connect_settings_signals(self):
+ """Connect all settings controls' signals to real-time saving"""
+ # Connect general widget settings
+ if hasattr(self, 'general_widget'):
+ self.general_widget.settings_changed.connect(self.on_settings_changed)
+
+ # Connect debug widget auto-save signals (already handled in DebugSettingsWidget)
+ # Debug settings are now handled by the DebugSettingsWidget itself
+
+ # Connect update dialog settings
+ # Update settings related signal connections have been removed, handled internally by UpdateDialog
+
+ def on_settings_changed(self):
+ """Handle any settings change and trigger auto-save"""
+ self.save_settings_async()
+
+ def save_settings_async(self):
+ """Asynchronously save settings in a separate thread"""
+ def save_thread():
+ settings = QSettings("MyCompany", "ConverterApp")
+ # Theme settings - always System Default
+ settings.setValue("theme", 0)
+
+ # Save General settings
+ if hasattr(self, 'general_widget'):
+ self.general_widget.save_settings()
+
+ # Debug settings are now handled by the DebugSettingsWidget itself
+
+ # Image converter settings are now saved by the general widget
+ # No separate image converter widget exists anymore
+
+ settings.sync() # Ensure settings are written to disk
+
+ # Start separate thread to execute save operation
+ threading.Thread(target=save_thread).start()
+
+
+class MainWindow(FluentWindow):
+ """Main application window"""
def _load_qss_file(self, filename):
"""Load QSS content from external file"""
@@ -84,13 +607,11 @@ def DARK_QSS(self):
def __init__(self, q_app: QApplication):
super().__init__()
self._q_app = q_app # Store QApplication instance
- self.setWindowTitle("Converter")
- # Load theme setting immediately
self.settings = QSettings("MyCompany", "ConverterApp")
self.theme_setting = self.settings.value("theme", 0, type=int)
self.themeListener = SystemThemeListener(self)
- self.path= os.path.dirname(os.path.abspath(__file__))
+ self.path = os.path.dirname(os.path.abspath(__file__))
# Define paths for icon files
self.app_icon_path = os.path.join(self.path,"AppIcon.png")
self.appd_icon_path = os.path.join(self.path,"AppIcond.png")
@@ -113,15 +634,94 @@ def __init__(self, q_app: QApplication):
print("Note: zipd.png file not found. Will try to create a PNG placeholder icon.")
create_placeholder_icon(self.zipd_icon_path, "dimgray", "ZipD")
- self.init_ui()
+ # Icon paths dictionary for home interface
+ self.icon_paths = {
+ 'app_icon_path': self.app_icon_path,
+ 'appd_icon_path': self.appd_icon_path,
+ 'zip_icon_path': self.zip_icon_path,
+ 'zipd_icon_path': self.zipd_icon_path
+ }
+
+ # Initialize interfaces
+ self.init_interfaces()
+
+ # Initialize window
+ self.init_window()
+ self.init_navigation()
+
+ # Initialize task manager
+ self.init_task_manager()
+
+ # Apply theme
setTheme(Theme.AUTO)
self.themeListener.start()
qconfig.themeChanged.connect(self._onThemeChanged)
- # Apply theme based on settings or initial system detection
- self._apply_system_theme_from_settings()
+ self._apply_system_theme_from_settings()
+
+ def init_interfaces(self):
+ """Initialize sub-interfaces"""
+ # Create home interface with app cards
+ self.home_interface = HomeInterface(self.icon_paths)
+
+ # Create task interface for task management
+ self.task_interface = TaskInterface()
+
+ # Create settings interface
+ self.settings_interface = SettingsInterface()
+
+ def init_window(self):
+ """Initialize window properties"""
+ self.setWindowTitle("Converter")
+ self.setWindowIcon(QIcon(self.app_icon_path))
+ self.resize(900, 700)
+
+ def init_navigation(self):
+ """Initialize navigation items"""
+ self.addSubInterface(
+ self.home_interface,
+ FIF.HOME,
+ 'Home'
+ )
+
+ self.addSubInterface(
+ self.task_interface,
+ FIF.HISTORY,
+ 'Task Manager'
+ )
+
+ self.addSubInterface(
+ self.settings_interface,
+ FIF.SETTING,
+ 'Settings',
+ NavigationItemPosition.BOTTOM
+ )
def closeEvent(self, event):
"""窗口关闭事件"""
- # 停止监听器线程
+ # Check if task mode is enabled and if any sub-windows are open
+ task_mode_enabled = self.settings.value("task_mode", False, type=bool)
+ if task_mode_enabled:
+ has_open_windows = False
+ # Get all top level widgets
+ for widget in self._q_app.topLevelWidgets():
+ # Check if there are any image or arc windows open
+ if widget is not self:
+ window_title = widget.windowTitle()
+ if "Image Converter" in window_title or "Archive File Processing Tool" in window_title:
+ has_open_windows = True
+ break
+
+ if has_open_windows:
+ # Show modal dialog explaining why closing is not allowed
+ QMessageBox.information(
+ self,
+ "Cannot Close",
+ "Task Mode is enabled and sub-windows are open. Please close all sub-windows first.",
+ QMessageBox.StandardButton.Ok
+ )
+ event.ignore()
+ return
+
+ # Stop listener thread
if hasattr(self, 'themeListener'):
self.themeListener.terminate()
self.themeListener.deleteLater()
@@ -168,8 +768,59 @@ def update_sub_widgets_theme(self, is_dark_mode):
self._settings_dialog.apply_theme(is_dark_mode)
def init_ui(self):
- # Create main layout
- main_layout = QVBoxLayout()
+ # Create main horizontal layout for sidebar and content
+ main_horizontal_layout = QHBoxLayout()
+
+ # --- Task Sidebar ---
+ # Create sidebar widget
+ from qfluentwidgets import PushButton, setCustomStyleSheet
+
+ # Keep using QWidget as the container
+ self.sidebar_widget = QWidget()
+ self.sidebar_widget.setObjectName("sidebar_widget")
+ self.sidebar_layout = QVBoxLayout(self.sidebar_widget)
+
+ # Sidebar title
+ sidebar_title = QLabel("Task Manager")
+ sidebar_title.setObjectName("sidebar_title")
+ sidebar_title.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.sidebar_layout.addWidget(sidebar_title)
+
+ # Task list
+ self.task_list = QListWidget()
+ self.task_list.setObjectName("task_list")
+ from con import CON
+ setCustomStyleSheet(self.task_list, CON.qss_combo, CON.qss_combo)
+ self.sidebar_layout.addWidget(self.task_list)
+
+ # Sidebar control buttons
+ sidebar_controls = QHBoxLayout()
+
+ # Clear completed tasks button
+ self.clear_tasks_button = PushButton("Clear Completed")
+ self.clear_tasks_button.setObjectName("clear_tasks_button")
+ self.clear_tasks_button.setIconSize(QSize(16, 16))
+ setCustomStyleSheet(self.clear_tasks_button, CON.qss, CON.qss)
+ sidebar_controls.addWidget(self.clear_tasks_button)
+
+ # Collapse/Expand button
+ self.toggle_sidebar_button = PushButton("Collapse")
+ self.toggle_sidebar_button.setObjectName("toggle_sidebar_button")
+ self.toggle_sidebar_button.setIconSize(QSize(16, 16))
+ self.toggle_sidebar_button.clicked.connect(self.toggle_sidebar)
+ setCustomStyleSheet(self.toggle_sidebar_button, CON.qss, CON.qss)
+ sidebar_controls.addWidget(self.toggle_sidebar_button)
+
+ self.sidebar_layout.addLayout(sidebar_controls)
+
+ # Add sidebar to main horizontal layout
+ self.sidebar_widget.setFixedWidth(250)
+ main_horizontal_layout.addWidget(self.sidebar_widget)
+
+ # --- Main Content ---
+ # Create main content widget with vertical layout
+ content_widget = QWidget()
+ main_layout = QVBoxLayout(content_widget)
main_layout.setSpacing(25) # Increased spacing for better visual separation
main_layout.setContentsMargins(40, 35, 40, 35) # Better margins
@@ -258,14 +909,183 @@ def init_ui(self):
settings_button_layout.addWidget(settings_button)
settings_button_layout.addStretch()
main_layout.addLayout(settings_button_layout)
-
- # Set the main layout for the window
- self.setLayout(main_layout)
+
+ # Add content widget to main horizontal layout
+ main_horizontal_layout.addWidget(content_widget, 1) # Give content stretch priority
+
+ # Add task count indicator (visible when sidebar is collapsed)
+ self.task_count_indicator = QLabel("0")
+ self.task_count_indicator.setObjectName("task_count_indicator")
+ self.task_count_indicator.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.task_count_indicator.setStyleSheet("""
+ background-color: #0078d4;
+ color: white;
+ border-radius: 12px;
+ padding: 5px 10px;
+ font-weight: bold;
+ """)
+ self.task_count_indicator.setFixedSize(30, 30)
+ self.task_count_indicator.hide() # Initially hidden
+
+ # Set the main horizontal layout for the window
+ self.setLayout(main_horizontal_layout)
+
+ # Initialize task manager
+ self.init_task_manager()
def show_settings(self):
settings_dialog = SettingsDialog(self)
self._settings_dialog = settings_dialog # Save dialog reference
settings_dialog.show() # Use show() instead of exec() to keep dialog non-modal
+
+ def toggle_sidebar(self):
+ """Toggle sidebar visibility"""
+ if self.sidebar_widget.isVisible():
+ # Hide sidebar and show task count indicator
+ self.sidebar_widget.hide()
+ self.task_count_indicator.show()
+ self.toggle_sidebar_button.setText("Expand")
+ else:
+ # Show sidebar and hide task count indicator
+ self.sidebar_widget.show()
+ self.task_count_indicator.hide()
+ self.toggle_sidebar_button.setText("Collapse")
+
+ def init_task_manager(self):
+ """Initialize task manager and connect signals"""
+ # Import task manager
+ from support.task_manager import TaskManager
+
+ # Create task manager instance
+ self.task_manager = TaskManager()
+
+ # Connect task manager signals to UI updates
+ self.task_manager.task_added.connect(self._on_task_added)
+ self.task_manager.task_updated.connect(self._on_task_updated)
+ self.task_manager.task_completed.connect(self._on_task_completed)
+ self.task_manager.task_failed.connect(self._on_task_failed)
+ self.task_manager.progress_updated.connect(self._on_task_progress_updated)
+
+ # Connect task interface control buttons
+ self.task_interface.clear_tasks_button.clicked.connect(self._clear_completed_tasks)
+
+ # Start task file monitor
+ self._start_task_monitor()
+
+ def _start_task_monitor(self):
+ """Start task file monitor to check for new or updated tasks"""
+ # Create a timer to check task files every 1 second
+ self.task_monitor_timer = QTimer(self)
+ self.task_monitor_timer.timeout.connect(self._check_task_files)
+ self.task_monitor_timer.start(1000) # Check every 1 second
+
+ # Create task directory if it doesn't exist
+ self.task_dir = os.path.expanduser("~/.converter/tasks")
+ os.makedirs(self.task_dir, exist_ok=True)
+
+ def _check_task_files(self):
+ """Check for new or updated task files"""
+ import json
+
+ # Get all task files in the directory
+ try:
+ task_files = [f for f in os.listdir(self.task_dir) if f.startswith("task_") and f.endswith(".json")]
+
+ for task_file in task_files:
+ task_file_path = os.path.join(self.task_dir, task_file)
+ task_id = task_file[5:-5] # Extract task_id from filename
+
+ # Read task info from file
+ with open(task_file_path, "r") as f:
+ task_info = json.load(f)
+
+ # Check if task already exists in task manager
+ if task_id in self.task_manager.tasks:
+ # Update existing task
+ self._update_existing_task(task_id, task_info)
+ else:
+ # Add new task
+ self._add_new_task(task_id, task_info)
+
+ except Exception as e:
+ print(f"Error checking task files: {e}")
+
+ def _add_new_task(self, task_id, task_info):
+ """Add a new task to task manager"""
+ # Create task using task manager's add_task method
+ self.task_manager.add_task(
+ task_info["task_type"],
+ task_info["input_path"],
+ task_info["output_path"]
+ )
+
+ def _update_existing_task(self, task_id, task_info):
+ """Update an existing task in task manager"""
+ # Update task progress
+ self.task_manager.tasks[task_id].progress = task_info["progress"]
+ self.task_manager.tasks[task_id].status = task_info["status"]
+
+ # Emit progress updated signal
+ self.task_manager.progress_updated.emit(task_id, task_info["progress"])
+
+ # If task is completed or failed, emit appropriate signal
+ if task_info["status"] == "completed":
+ self.task_manager.task_completed.emit(task_id, task_info)
+ elif task_info["status"] == "failed":
+ self.task_manager.task_failed.emit(task_id, task_info, task_info.get("error", "Unknown error"))
+
+ def _on_task_added(self, task_id, task_info):
+ """Handle new task added"""
+ self.task_interface.add_task_card(task_id, task_info['task_type'], task_info)
+ self._update_task_count()
+
+ def _on_task_updated(self, task_id, task_info):
+ """Handle task updated"""
+ self.task_interface.update_task_card(task_id, task_info)
+
+ def _on_task_completed(self, task_id, task_info):
+ """Handle task completed"""
+ self._on_task_updated(task_id, task_info)
+
+ # Send system notification
+ from support.notification import send_notification
+ send_notification(
+ "Task Completed",
+ f"{task_info['task_type'].capitalize()} conversion completed: {os.path.basename(task_info['input_path'])}"
+ )
+
+ def _on_task_failed(self, task_id, task_info, error):
+ """Handle task failed"""
+ # Update task in list
+ self._on_task_updated(task_id, task_info)
+
+ # Send system notification
+ from support.notification import send_notification
+ send_notification(
+ "Task Failed",
+ f"{task_info['task_type'].capitalize()} conversion failed: {os.path.basename(task_info['input_path'])}"
+ )
+
+ def _on_task_progress_updated(self, task_id, progress):
+ """Handle task progress updated"""
+ for task_id_key in self.task_interface.task_cards:
+ if task_id_key == task_id:
+ task_info = self.task_manager.tasks.get(task_id, {})
+ if task_info:
+ task_info['progress'] = progress
+ self.task_interface.update_task_card(task_id, task_info)
+ break
+
+ def _clear_completed_tasks(self):
+ """Clear completed tasks from list"""
+ self.task_manager.clear_completed_tasks()
+ self.task_interface.clear_all_cards()
+ self._update_task_count()
+
+ def _update_task_count(self):
+ """Update task count indicator (no longer needed in FluentWindow)"""
+ # Task count indicator is no longer needed in FluentWindow
+ pass
class AnimatedAppDialog(QDialog):
def __init__(self, parent=None, app_type=""):
@@ -420,16 +1240,25 @@ def run_image_app():
try:
# Get the main window instance
app = QApplication.instance()
+ if app is None:
+ return
main_window = None
for widget in app.topLevelWidgets():
- if isinstance(widget, IconButtonsWindow):
+ if isinstance(widget, MainWindow):
main_window = widget
break
if main_window:
- # Create and show the animation dialog
- dialog = ImageAppDialog(main_window)
- dialog.show()
+ # Check if task mode is enabled
+ task_mode_enabled = main_window.settings.value("task_mode", False, type=bool)
+
+ if task_mode_enabled:
+ # In task mode, run directly in the same process
+ run_image()
+ else:
+ # Create and show the animation dialog
+ dialog = ImageAppDialog(main_window)
+ dialog.show()
else:
# Fallback to multiprocessing if no main window found
multiprocessing.Process(target=run_image).start()
@@ -444,16 +1273,25 @@ def run_zip_app():
try:
# Get the main window instance
app = QApplication.instance()
+ if app is None:
+ return
main_window = None
for widget in app.topLevelWidgets():
- if isinstance(widget, IconButtonsWindow):
+ if isinstance(widget, MainWindow):
main_window = widget
break
if main_window:
- # Create and show the animation dialog
- dialog = ZipAppDialog(main_window)
- dialog.show()
+ # Check if task mode is enabled
+ task_mode_enabled = main_window.settings.value("task_mode", False, type=bool)
+
+ if task_mode_enabled:
+ # In task mode, run directly in the same process
+ run_zip()
+ else:
+ # Create and show the animation dialog
+ dialog = ZipAppDialog(main_window)
+ dialog.show()
else:
# Fallback to multiprocessing if no main window found
multiprocessing.Process(target=run_zip).start()
@@ -481,7 +1319,7 @@ def run_zip_app():
from support.toggle import theme_manager
theme_manager.start()
setTheme(Theme.AUTO)
- window = IconButtonsWindow(q_app=app)
+ window = MainWindow(q_app=app)
window.show()
# Connect to palette changes for real-time theme switching ONLY if setting is System Default
app.paletteChanged.connect(lambda: window._apply_system_theme(app.palette().color(QPalette.ColorRole.Window).lightnessF() < 0.5))
diff --git a/Converter.spec b/Converter.spec
index 0c8a875..c6b098a 100644
--- a/Converter.spec
+++ b/Converter.spec
@@ -261,12 +261,12 @@ app = BUNDLE(
coll,
name='Converter.app',
icon=os.path.join(current_dir, 'AppIcon.icns'),
- bundle_identifier='com.pyquick.converter',
+ bundle_identifier='com.intsant.converter',
info_plist={
'CFBundleDisplayName': 'Converter',
'CFBundleExecutable': 'Converter',
'CFBundleIconFile': 'AppIcon.icns',
- 'CFBundleIdentifier': 'com.pyquick.converter',
+ 'CFBundleIdentifier': 'com.intsant.converter',
'CFBundleInfoDictionaryVersion': '6.0',
'CFBundleName': 'Converter',
'CFBundlePackageType': 'APPL',
@@ -275,5 +275,5 @@ app = BUNDLE(
'NSHighResolutionCapable': True,
'LSMinimumSystemVersion': '11.7',
},
- version='2.0.0',
+ version='2.1.0A6',
)
\ No newline at end of file
diff --git a/LICENSE.txt b/LICENSE.txt
new file mode 100644
index 0000000..53d1f3d
--- /dev/null
+++ b/LICENSE.txt
@@ -0,0 +1,675 @@
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
+
diff --git a/arc_gui.py b/arc_gui.py
index 6e8f913..fdf01fa 100644
--- a/arc_gui.py
+++ b/arc_gui.py
@@ -5,8 +5,8 @@
import shutil
from pathlib import Path
-from PySide6.QtCore import QThread, Signal, Qt, QTimer, QUrl, QObject
-from PySide6.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QHBoxLayout,
+from PySide6.QtCore import QThread, Signal, Qt, QTimer, QUrl, QObject, QSize, QSettings
+from PySide6.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QHBoxLayout, QGridLayout,
QPushButton, QLabel, QLineEdit, QTextEdit, QProgressBar,
QTabWidget, QWidget, QGroupBox, QListWidget, QListWidgetItem,
QFileDialog, QCheckBox, QComboBox, QFrame, QMessageBox, QMenu)
@@ -15,10 +15,12 @@
from con import CON
from support.toggle import ThemeManager
+from support.GUI.arc_support import BatchDropZoneWidget
# Add the current directory to Python path to import convertzip module
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
-from support.archive_manager import create_archive, extract_archive, add_to_archive, list_archive_contents, SUPPORTED_ARCHIVE_FORMATS
-from support.password_detector import PasswordDetector, detect_password_protection
+from support.archive_manager import create_archive, extract_archive, add_to_archive, list_archive_contents, SUPPORTED_ARCHIVE_FORMATS, batch_extract_archives
+from support.password_detector import PasswordDetector
+from password_dialog import PasswordDialog, SimplePasswordDialog
# Remove the problematic reconfigure calls
# sys.stdout.reconfigure(encoding='utf-8')
@@ -28,6 +30,7 @@ class CreateZipWorker(QObject):
finished = Signal()
progress_updated = Signal(str, int)
conversion_error = Signal(str)
+ canceled = Signal() # Add canceled signal
def __init__(self, output_path, sources, archive_format, password=None):
super().__init__()
@@ -35,6 +38,12 @@ def __init__(self, output_path, sources, archive_format, password=None):
self.sources = sources
self.archive_format = archive_format
self.password = password
+ self.is_stopped = False # Add stop flag
+
+ def stop(self):
+ """Stop the archive creation process"""
+ self.is_stopped = True
+ self.progress_updated.emit("Canceling archive creation...", 0)
def run(self):
try:
@@ -51,8 +60,22 @@ def run(self):
if not os.path.exists(source):
raise ValueError(f"Source file does not exist: {source}")
- create_archive(self.output_path, self.sources, self.archive_format, self._update_progress_callback, self.password)
- self.finished.emit()
+ # Update progress callback to check for cancellation
+ def progress_callback(message, percentage):
+ if self.is_stopped:
+ raise RuntimeError("Archive creation canceled by user")
+ self.progress_updated.emit(message, percentage)
+
+ create_archive(self.output_path, self.sources, self.archive_format, progress_callback, self.password)
+ if not self.is_stopped:
+ self.finished.emit()
+ else:
+ self.canceled.emit()
+ except RuntimeError as e:
+ if "canceled" in str(e).lower():
+ self.canceled.emit()
+ else:
+ self.conversion_error.emit(str(e))
except ValueError as e:
# Handle value errors
self.conversion_error.emit(f"Input error: {str(e)}")
@@ -70,9 +93,12 @@ def run(self):
self.conversion_error.emit(str(e))
except Exception as e:
# Handle all other exceptions
- import traceback
- error_msg = f"Unexpected error: {str(e)}\n{traceback.format_exc()}"
- self.conversion_error.emit(error_msg)
+ if self.is_stopped:
+ self.canceled.emit()
+ else:
+ import traceback
+ error_msg = f"Unexpected error: {str(e)}\n{traceback.format_exc()}"
+ self.conversion_error.emit(error_msg)
def _update_progress_callback(self, message, percentage):
self.progress_updated.emit(message, percentage)
@@ -82,25 +108,47 @@ class ExtractZipWorker(QObject):
progress_updated = Signal(str, int)
conversion_error = Signal(str)
password_required = Signal(str) # Emits error message when password is required
+ canceled = Signal() # Add canceled signal
def __init__(self, zip_path, dest_path, password=None):
super().__init__()
self.archive_path = zip_path # Renamed for clarity with generic archive_manager
self.extract_to = dest_path
self.password = password
+ self.is_stopped = False # Add stop flag
+
+ def stop(self):
+ """Stop the archive extraction process"""
+ self.is_stopped = True
+ self.progress_updated.emit("Canceling archive extraction...", 0)
def run(self):
try:
- extract_archive(self.archive_path, self.extract_to, self._update_progress_callback, self.password)
- self.finished.emit()
+ # Update progress callback to check for cancellation
+ def progress_callback(message, percentage):
+ if self.is_stopped:
+ raise RuntimeError("Archive extraction canceled by user")
+ self.progress_updated.emit(message, percentage)
+
+ extract_archive(self.archive_path, self.extract_to, progress_callback, self.password)
+ if not self.is_stopped:
+ self.finished.emit()
+ else:
+ self.canceled.emit()
except RuntimeError as e:
- # Handle password required case
- if "password" in str(e).lower() or "encrypted" in str(e).lower():
- self.password_required.emit(str(e))
+ if "canceled" in str(e).lower():
+ self.canceled.emit()
else:
- self.conversion_error.emit(str(e))
+ # Handle password required case
+ if "password" in str(e).lower() or "encrypted" in str(e).lower():
+ self.password_required.emit(str(e))
+ else:
+ self.conversion_error.emit(str(e))
except Exception as e:
- self.conversion_error.emit(str(e))
+ if self.is_stopped:
+ self.canceled.emit()
+ else:
+ self.conversion_error.emit(str(e))
def _update_progress_callback(self, message, percentage):
self.progress_updated.emit(message, percentage)
@@ -109,26 +157,48 @@ class AddToZipWorker(QObject):
finished = Signal()
progress_updated = Signal(str, int)
conversion_error = Signal(str)
+ canceled = Signal() # Add canceled signal
def __init__(self, zip_path, file_paths):
super().__init__()
self.archive_path = zip_path # Renamed for clarity with generic archive_manager
self.files_to_add = file_paths if isinstance(file_paths, list) else [file_paths]
+ self.is_stopped = False # Add stop flag
+
+ def stop(self):
+ """Stop the add to archive process"""
+ self.is_stopped = True
+ self.progress_updated.emit("Canceling add to archive...", 0)
def run(self):
try:
# Handle multiple files
total_files = len(self.files_to_add)
for i, file_path in enumerate(self.files_to_add):
+ # Check if canceled before processing each file
+ if self.is_stopped:
+ raise RuntimeError("Add to archive canceled by user")
+
self._update_progress_callback(f"Adding file {i+1}/{total_files}: {os.path.basename(file_path)}", (i/total_files)*100)
add_to_archive(self.archive_path, file_path, None) # No individual progress for each file
- self._update_progress_callback(f"Added {total_files} files to archive", 100)
- self.finished.emit()
+ if not self.is_stopped:
+ self._update_progress_callback(f"Added {total_files} files to archive", 100)
+ self.finished.emit()
+ else:
+ self.canceled.emit()
+ except RuntimeError as e:
+ if "canceled" in str(e).lower():
+ self.canceled.emit()
+ else:
+ self.conversion_error.emit(str(e))
except NotImplementedError as e:
self.conversion_error.emit(str(e))
except Exception as e:
- self.conversion_error.emit(str(e))
+ if self.is_stopped:
+ self.canceled.emit()
+ else:
+ self.conversion_error.emit(str(e))
def _update_progress_callback(self, message, percentage):
self.progress_updated.emit(message, percentage)
@@ -137,32 +207,227 @@ class ListZipContentsWorker(QObject):
finished = Signal(list) # Emits list of contents
conversion_error = Signal(str)
password_required = Signal(str) # Emits error message when password is required
+ canceled = Signal() # Add canceled signal
def __init__(self, zip_path, password=None):
super().__init__()
self.archive_path = zip_path # Renamed for clarity with generic archive_manager
self.password = password
self.result = None # Add result attribute to store results
+ self.is_stopped = False # Add stop flag
+
+ def stop(self):
+ """Stop the list contents process"""
+ self.is_stopped = True
+ print(f"[DEBUG] ListZipContentsWorker: Stop requested")
def run(self):
try:
+ # Check if canceled before starting
+ if self.is_stopped:
+ self.canceled.emit()
+ return
+
print(f"[DEBUG] ListZipContentsWorker: Starting to list contents of {self.archive_path}")
+ # This operation is typically fast, but we'll still add a cancel check
contents = list_archive_contents(self.archive_path, password=self.password)
print(f"[DEBUG] ListZipContentsWorker: Got {len(contents) if contents else 0} items")
- self.result = contents # 设置result属性
- self.finished.emit(contents)
+
+ if not self.is_stopped:
+ self.result = contents # 设置result属性
+ self.finished.emit(contents)
+ else:
+ self.canceled.emit()
except RuntimeError as e:
- # Handle password required case
- print(f"[DEBUG] ListZipContentsWorker: RuntimeError - {str(e)}")
- if "password" in str(e).lower() or "encrypted" in str(e).lower():
- self.password_required.emit(str(e))
+ if not self.is_stopped:
+ # Handle password required case
+ print(f"[DEBUG] ListZipContentsWorker: RuntimeError - {str(e)}")
+ if "password" in str(e).lower() or "encrypted" in str(e).lower():
+ self.password_required.emit(str(e))
+ else:
+ self.conversion_error.emit(str(e))
else:
+ self.canceled.emit()
+ except Exception as e:
+ if not self.is_stopped:
+ print(f"[DEBUG] ListZipContentsWorker: Exception - {str(e)}")
+ import traceback
+ traceback.print_exc()
self.conversion_error.emit(str(e))
+ else:
+ self.canceled.emit()
+
+class BatchExtractWorker(QObject):
+ """Worker for batch archive extraction"""
+ finished = Signal(int, int, list, list) # Emits success_count, failed_count, success_files, failed_files
+ progress_updated = Signal(int, int, str, int, int) # processed_count, total_count, current_file, success_count, failed_count
+ conversion_error = Signal(str) # Emits error messages
+ individual_progress = Signal(str, str, int) # Emits archive name, message, percentage
+ status_updated = Signal(str) # Emits status messages
+ canceled = Signal() # Add canceled signal for consistency
+
+ def __init__(self, archive_paths, dest_folder, create_subfolders=True, overwrite_files=False, parent_gui=None):
+ super().__init__()
+ self.archive_paths = archive_paths
+ self.dest_folder = dest_folder
+ self.create_subfolders = create_subfolders
+ self.overwrite_files = overwrite_files
+ self.is_stopped = False
+ self.parent_gui = parent_gui # Reference to main GUI for password dialogs
+
+ # Track detailed statistics
+ self.success_count = 0
+ self.failed_count = 0
+ self.success_files = []
+ self.failed_files = []
+
+ def stop(self):
+ """Stop the batch extraction process"""
+ self.is_stopped = True
+ self.status_updated.emit("Stopping batch extraction...")
+
+ def run(self):
+ """Execute batch extraction"""
+ try:
+ if not self.archive_paths:
+ raise ValueError("No archive files to extract")
+
+ if not self.dest_folder:
+ raise ValueError("Destination folder is not specified")
+
+ # Ensure destination folder exists
+ if not os.path.exists(self.dest_folder):
+ try:
+ os.makedirs(self.dest_folder, exist_ok=True)
+ except Exception as e:
+ raise ValueError(f"Failed to create destination folder: {str(e)}")
+
+ # Initialize password detector
+ password_detector = PasswordDetector()
+
+ # Reset statistics
+ self.success_count = 0
+ self.failed_count = 0
+ self.success_files = []
+ self.failed_files = []
+
+ total_files = len(self.archive_paths)
+ self.status_updated.emit(f"Starting batch extraction of {total_files} archive(s)...")
+
+ def progress_callback(current, total, current_file=""):
+ if self.is_stopped:
+ return # Stop processing if requested
+
+ # Handle different call patterns
+ if isinstance(current, str):
+ # Called with (message, progress_percent) pattern for individual file extraction
+ message = current
+ progress_percent = total
+ archive_name = os.path.basename(current_file) if current_file else ""
+ # Emit individual file progress
+ self.individual_progress.emit(archive_name, message, int(progress_percent))
+ else:
+ # Called with (current, total, current_file) pattern for batch progress
+ current_val = int(current) if isinstance(current, (int, float)) else 0
+ total_val = int(total) if isinstance(total, (int, float)) else total_files
+
+ # Get current file path
+ if isinstance(current, int) and 1 <= current <= total_files:
+ current_file_path = self.archive_paths[current - 1]
+ else:
+ current_file_path = str(current_file) if current_file else ""
+
+ # Calculate overall progress percentage
+ overall_progress = (current_val / total_val * 100) if total_val > 0 else 0
+
+ # Emit batch progress update
+ self.progress_updated.emit(current_val, total_val, current_file_path, self.success_count, self.failed_count)
+
+ def password_callback(archive_path, format_name, is_protected):
+ """Callback to request password from user via GUI"""
+ if self.is_stopped:
+ return None
+ if self.parent_gui and hasattr(self.parent_gui, 'request_password'):
+ try:
+ # Request password from main GUI thread
+ return self.parent_gui.request_password(archive_path, format_name, is_protected)
+ except Exception as e:
+ self.conversion_error.emit(f"Error requesting password: {str(e)}")
+ return None
+ return None
+
+ def error_callback(archive_path, error_message):
+ """Callback for individual archive errors"""
+ self.failed_count += 1 # Increment failed count
+ self.failed_files.append((archive_path, error_message))
+ self.conversion_error.emit(f"Error processing {os.path.basename(archive_path)}: {error_message}")
+ # Update progress after error
+ processed = self.success_count + self.failed_count
+ self.progress_updated.emit(processed, total_files, archive_path, self.success_count, self.failed_count)
+
+ def success_callback(archive_path):
+ """Callback for successful archive extraction"""
+ self.success_count += 1 # Increment success count
+ self.success_files.append(archive_path)
+ # Update progress after success
+ processed = self.success_count + self.failed_count
+ self.progress_updated.emit(processed, total_files, archive_path, self.success_count, self.failed_count)
+
+ # Prepare options for batch extraction
+ options = {
+ 'create_subfolders': self.create_subfolders,
+ 'overwrite_existing': self.overwrite_files,
+ 'progress_callback': progress_callback if not self.is_stopped else None,
+ 'password_callback': password_callback if not self.is_stopped else None,
+ 'password_detector': password_detector,
+ 'error_callback': error_callback,
+ 'success_callback': success_callback
+ }
+
+ if self.is_stopped:
+ self.status_updated.emit("Batch extraction stopped by user")
+ self.finished.emit(self.success_count, self.failed_count, self.success_files, self.failed_files)
+ return
+
+ # Call batch extraction function with password detection
+ result = batch_extract_archives(
+ self.archive_paths,
+ self.dest_folder,
+ **options
+ )
+
+ if not self.is_stopped:
+ # Update final statistics from result
+ self.success_count = result.get('success_count', self.success_count)
+ self.failed_count = result.get('error_count', self.failed_count)
+
+ # Emit final status
+ self.status_updated.emit(f"Batch extraction completed: {self.success_count} successful, {self.failed_count} failed")
+
+ # Emit finished signal with detailed results
+ self.finished.emit(self.success_count, self.failed_count, self.success_files, self.failed_files)
+ else:
+ self.status_updated.emit("Batch extraction stopped by user")
+ # Emit canceled signal instead of finished for consistency with other workers
+ self.canceled.emit()
+ self.finished.emit(self.success_count, self.failed_count, self.success_files, self.failed_files)
+
+ except ValueError as e:
+ error_msg = f"Input error: {str(e)}"
+ self.conversion_error.emit(error_msg)
+ self.status_updated.emit(f"Batch extraction failed: {error_msg}")
+ self.finished.emit(0, total_files if 'total_files' in locals() else 0, [], self.archive_paths)
+ except RuntimeError as e:
+ error_msg = str(e)
+ self.conversion_error.emit(error_msg)
+ self.status_updated.emit(f"Batch extraction failed: {error_msg}")
+ self.finished.emit(0, total_files if 'total_files' in locals() else 0, [], self.archive_paths)
except Exception as e:
- print(f"[DEBUG] ListZipContentsWorker: Exception - {str(e)}")
import traceback
- traceback.print_exc()
- self.conversion_error.emit(str(e))
+ error_msg = f"Unexpected error during batch extraction: {str(e)}\n{traceback.format_exc()}"
+ self.conversion_error.emit(error_msg)
+ self.status_updated.emit(f"Batch extraction failed unexpectedly")
+ self.finished.emit(0, total_files if 'total_files' in locals() else 0, [], self.archive_paths)
class ZipGUI(QMainWindow):
@@ -220,8 +485,8 @@ def DARK_QSS(self):
def __init__(self, initial_dark_mode=False):
super().__init__()
self.setWindowTitle("Archive File Processing Tool")
- self.setGeometry(200, 200, 800, 600)
- self.setMinimumSize(600, 780)
+ self.setGeometry(200, 200, 1200, 900)
+ self.setMinimumSize(1200, 900)
# Enable drag and drop for the main window
self.setAcceptDrops(True)
@@ -235,6 +500,9 @@ def __init__(self, initial_dark_mode=False):
setTheme(Theme.AUTO)
self.themeListener.start()
qconfig.themeChanged.connect(self._onThemeChanged)
+
+ # Load settings
+ self.load_settings()
def closeEvent(self, event):
"""Window close event"""
# Stop listener thread
@@ -245,6 +513,12 @@ def closeEvent(self, event):
def _onThemeChanged(self, theme: Theme):
"""Theme change handling"""
# Update interface to respond to theme changes
+ is_dark_mode = theme == Theme.DARK
+
+ # Update drag and drop area theme
+ if hasattr(self, 'batch_drop_area'):
+ self.batch_drop_area.set_theme(is_dark_mode)
+
self.update()
setTheme(Theme.AUTO)
def init_variables(self):
@@ -255,12 +529,25 @@ def init_variables(self):
self.create_zip_worker_thread = None # Renamed to generic for clarity
self.create_zip_worker = None # Renamed to generic for clarity
+ # Task mode setting
+ self.task_mode = False
+
# Variables for Extract ZIP tab
self.extract_zip_path = ""
self.extract_dest_path = ""
self.extract_zip_worker_thread = None # Renamed to generic for clarity
self.extract_zip_worker = None # Renamed to generic for clarity
+ # Variables for Batch Extract tab
+ self.batch_extract_files = []
+ self.batch_extract_dest_path = ""
+ self.batch_extract_worker = None
+ self.batch_extract_worker_thread = None
+ self.batch_extract_running = False
+ self.batch_extract_success_count = 0
+ self.batch_extract_failed_count = 0
+ self.batch_extract_password = None
+
# Variables for Add to ZIP tab
self.add_zip_path = ""
self.add_file_path = ""
@@ -275,6 +562,35 @@ def init_variables(self):
# Password protection status for archive contents
self.is_password_protected = False
self._current_password = None
+ self._password_dialog = None
+
+ def request_password(self, archive_path, format_name, is_protected):
+ """Request password from user for a protected archive"""
+ archive_name = os.path.basename(archive_path)
+ title = "Password Required"
+
+ if is_protected:
+ content = f"The archive '{archive_name}' ({format_name.upper()}) is password protected.\nPlease enter the password:"
+ else:
+ content = f"Enter password for archive '{archive_name}' ({format_name.upper()}):"
+
+ # Create and show password dialog
+ self._password_dialog = PasswordDialog(
+ parent=self,
+ title=title,
+ content=content,
+ error_message=""
+ )
+
+ # Show dialog and get result
+ if self._password_dialog.exec() == PasswordDialog.DialogCode.Accepted:
+ password = self._password_dialog.get_password()
+ self._password_dialog = None
+ return password
+ else:
+ # User canceled
+ self._password_dialog = None
+ return None
def setup_ui(self):
self.main_widget = QWidget(self)
@@ -307,9 +623,34 @@ def _apply_theme(self, is_dark_mode):
self.setStyleSheet(self.DARK_QSS)
else:
self.setStyleSheet(self.LIGHT_QSS)
+
+ # Update drag and drop area theme
+ if hasattr(self, 'batch_drop_area'):
+ self.batch_drop_area.set_theme(is_dark_mode)
def _apply_system_theme(self, is_dark_mode):
self._apply_theme(is_dark_mode)
+
+ def load_settings(self):
+ """Load settings from QSettings"""
+ settings = QSettings("MyCompany", "ConverterApp")
+
+ # Load task mode setting
+ self.task_mode = settings.value("task_mode", False, type=bool)
+ if hasattr(self, 'task_mode_check'):
+ self.task_mode_check.setChecked(self.task_mode)
+
+ def save_settings(self):
+ """Save settings to QSettings"""
+ settings = QSettings("MyCompany", "ConverterApp")
+
+ # Save task mode setting
+ if hasattr(self, 'task_mode_check'):
+ settings.setValue("task_mode", self.task_mode_check.isChecked())
+ else:
+ settings.setValue("task_mode", self.task_mode)
+
+ settings.sync()
def center_window(self):
qr = self.frameGeometry()
@@ -334,17 +675,36 @@ def create_create_tab(self):
tab_sizer = QVBoxLayout(tab_panel)
self.notebook.addTab(tab_panel, "Create Archive") # Changed tab title
- # Output file selection
+ # Output file selection with ScrollArea inside GroupBox
output_box = QGroupBox("Output Archive File") # Changed group box title
- output_box_sizer = QHBoxLayout(output_box)
+ output_box.setMinimumHeight(200) # Set minimum height
+ output_box_sizer = QVBoxLayout(output_box)
+
+ # Create ScrollArea inside GroupBox
+ output_scroll_area = ScrollArea()
+ output_scroll_area.setWidgetResizable(True)
+ output_scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+ output_scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+
+ # Create content widget for ScrollArea
+ content_widget = QWidget()
+ content_layout = QHBoxLayout(content_widget)
self.create_output_text = LineEdit()
setCustomStyleSheet(self.create_output_text, CON.qss_line, CON.qss_line)
# self.create_output_text.setReadOnly(True) # Allow users to manually input path
- output_box_sizer.addWidget(self.create_output_text, 1)
+ content_layout.addWidget(self.create_output_text, 1)
output_button = PushButton("Browse...")
output_button.clicked.connect(self.browse_create_output)
- output_box_sizer.addWidget(output_button)
+ content_layout.addWidget(output_button)
+
+ # Set content widget to ScrollArea
+ output_scroll_area.setWidget(content_widget)
+
+ # Add ScrollArea to GroupBox layout
+ output_box_sizer.addWidget(output_scroll_area)
+
+ # Add GroupBox to main layout
tab_sizer.addWidget(output_box)
# Archive Format Selection (new)
@@ -364,13 +724,24 @@ def create_create_tab(self):
format_layout.addWidget(self.create_format_combo, 1)
tab_sizer.addLayout(format_layout)
- # Source files list
+ # Source files list with ScrollArea inside GroupBox
sources_box = QGroupBox("Source Files/Directories")
+ sources_box.setMinimumHeight(200) # Set minimum height
sources_box_sizer = QVBoxLayout(sources_box)
+ # Create ScrollArea inside GroupBox
+ sources_scroll_area = ScrollArea()
+ sources_scroll_area.setWidgetResizable(True)
+ sources_scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+ sources_scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+
+ # Create content widget for ScrollArea
+ content_widget = QWidget()
+ content_layout = QVBoxLayout(content_widget)
+
self.sources_listbox = ListWidget()
self.sources_listbox.setMinimumHeight(280) # Set minimum height
- sources_box_sizer.addWidget(self.sources_listbox, 1) # Increase stretch weight
+ content_layout.addWidget(self.sources_listbox, 1) # Increase stretch weight
# Set right-click to immediately select
self.sources_listbox.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
# Context menu functionality removed
@@ -391,9 +762,22 @@ def create_create_tab(self):
button_sizer.addWidget(remove_button)
button_sizer.addStretch(1) # Push buttons to left
- sources_box_sizer.addLayout(button_sizer)
+ content_layout.addLayout(button_sizer)
+
+ # Set content widget to ScrollArea
+ sources_scroll_area.setWidget(content_widget)
+
+ # Add ScrollArea to GroupBox layout
+ sources_box_sizer.addWidget(sources_scroll_area)
+
+ # Add GroupBox to main layout
tab_sizer.addWidget(sources_box, 1) # Give sources box more stretch
+ # Task mode control
+ self.task_mode_check = CheckBox("Enable Task Mode")
+ self.task_mode_check.setChecked(False)
+ tab_sizer.addWidget(self.task_mode_check)
+
# Progress bar
self.create_progress_label = QLabel("")
tab_sizer.addWidget(self.create_progress_label)
@@ -403,11 +787,28 @@ def create_create_tab(self):
self.create_progress.setValue(0)
tab_sizer.addWidget(self.create_progress)
+ # Create button layout with cancel button
+ button_layout = QHBoxLayout()
+ button_layout.setSpacing(10)
+
# Create button
self.create_button = PrimaryPushButton("Create Archive") # Changed button text
-
self.create_button.clicked.connect(self.start_create_archive) # Changed signal
- tab_sizer.addWidget(self.create_button, 0, Qt.AlignmentFlag.AlignCenter)
+ button_layout.addWidget(self.create_button)
+
+ # Cancel button
+ self.create_cancel_button = PushButton("Cancel")
+ self.create_cancel_button.clicked.connect(self.cancel_create_archive)
+ self.create_cancel_button.setEnabled(False) # Initially disabled
+ button_layout.addWidget(self.create_cancel_button)
+
+ # Create a container widget to center the button layout
+ button_container = QWidget()
+ button_container_layout = QHBoxLayout(button_container)
+ button_container_layout.addStretch()
+ button_container_layout.addLayout(button_layout)
+ button_container_layout.addStretch()
+ tab_sizer.addWidget(button_container)
tab_sizer.addStretch(1) # Push content to top
@@ -416,33 +817,86 @@ def create_extract_tab(self):
tab_sizer = QVBoxLayout(tab_panel)
self.notebook.addTab(tab_panel, "Extract Archive") # Changed tab title
- # Archive file selection (changed title)
+ # Tab selector for single/batch extract
+ self.extract_tab_widget = QTabWidget()
+ tab_sizer.addWidget(self.extract_tab_widget)
+
+ # Single Extract Tab
+ self.create_single_extract_tab()
+
+ # Batch Extract Tab
+ self.create_batch_extract_tab()
+
+ tab_sizer.addStretch(1) # Push content to top
+
+ def create_single_extract_tab(self):
+ """Create single archive extraction tab"""
+ single_panel = QWidget()
+ single_sizer = QVBoxLayout(single_panel)
+
+ # Archive file selection with ScrollArea inside GroupBox (changed title)
zip_box = QGroupBox("Archive File to Extract")
- zip_box_sizer = QHBoxLayout(zip_box)
+ zip_box.setMinimumHeight(200) # Set minimum height
+ zip_box_sizer = QVBoxLayout(zip_box)
+ # Create ScrollArea inside GroupBox
+ zip_scroll_area = ScrollArea()
+ zip_scroll_area.setWidgetResizable(True)
+ zip_scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+ zip_scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+
+ # Create content widget for ScrollArea
+ content_widget = QWidget()
+ content_layout = QHBoxLayout(content_widget)
+
self.extract_zip_text = LineEdit()
setCustomStyleSheet(self.extract_zip_text, CON.qss_line, CON.qss_line)
# self.extract_zip_text.setReadOnly(True) # Allow users to manually input path
- zip_box_sizer.addWidget(self.extract_zip_text, 1)
+ content_layout.addWidget(self.extract_zip_text, 1)
zip_button = PushButton("Browse...")
-
zip_button.clicked.connect(self.browse_extract_archive) # Changed signal
- zip_box_sizer.addWidget(zip_button)
- tab_sizer.addWidget(zip_box)
+ content_layout.addWidget(zip_button)
+
+ # Set content widget to ScrollArea
+ zip_scroll_area.setWidget(content_widget)
+
+ # Add ScrollArea to GroupBox layout
+ zip_box_sizer.addWidget(zip_scroll_area)
+
+ # Add GroupBox to main layout
+ single_sizer.addWidget(zip_box)
- # Destination folder selection
+ # Destination folder selection with ScrollArea inside GroupBox
dest_box = QGroupBox("Destination Folder")
- dest_box_sizer = QHBoxLayout(dest_box)
+ dest_box.setMinimumHeight(200) # Set minimum height
+ dest_box_sizer = QVBoxLayout(dest_box)
+ # Create ScrollArea inside GroupBox
+ dest_scroll_area = ScrollArea()
+ dest_scroll_area.setWidgetResizable(True)
+ dest_scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+ dest_scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+
+ # Create content widget for ScrollArea
+ content_widget = QWidget()
+ content_layout = QHBoxLayout(content_widget)
+
self.extract_dest_text = LineEdit()
setCustomStyleSheet(self.extract_dest_text, CON.qss_line, CON.qss_line)
# self.extract_dest_text.setReadOnly(True) # Allow users to manually input path
- dest_box_sizer.addWidget(self.extract_dest_text, 1)
+ content_layout.addWidget(self.extract_dest_text, 1)
dest_button = PushButton("Browse...")
-
dest_button.clicked.connect(self.browse_extract_dest)
- dest_box_sizer.addWidget(dest_button)
- tab_sizer.addWidget(dest_box)
+ content_layout.addWidget(dest_button)
+
+ # Set content widget to ScrollArea
+ dest_scroll_area.setWidget(content_widget)
+
+ # Add ScrollArea to GroupBox layout
+ dest_box_sizer.addWidget(dest_scroll_area)
+
+ # Add GroupBox to main layout
+ single_sizer.addWidget(dest_box)
# Password status indicator
password_status_box = QHBoxLayout()
@@ -452,58 +906,358 @@ def create_extract_tab(self):
password_status_box.addWidget(self.extract_password_status_label)
password_status_box.addWidget(self.extract_password_status_icon)
password_status_box.addStretch()
- tab_sizer.addLayout(password_status_box)
+ single_sizer.addLayout(password_status_box)
# Progress bar
self.extract_progress_label = QLabel("")
- tab_sizer.addWidget(self.extract_progress_label)
+ single_sizer.addWidget(self.extract_progress_label)
self.extract_progress = ProgressBar()
self.extract_progress.setRange(0, 100)
self.extract_progress.setValue(0)
- tab_sizer.addWidget(self.extract_progress)
+ single_sizer.addWidget(self.extract_progress)
+ # Extract button layout with cancel button
+ button_layout = QHBoxLayout()
+ button_layout.setSpacing(10)
+
# Extract button
self.extract_button = PrimaryPushButton("Extract Archive") # Changed button text
-
self.extract_button.clicked.connect(self.start_extract_archive) # Changed signal
- tab_sizer.addWidget(self.extract_button, 0, Qt.AlignmentFlag.AlignCenter)
+ button_layout.addWidget(self.extract_button)
+
+ # Cancel button
+ self.extract_cancel_button = PushButton("Cancel")
+ self.extract_cancel_button.clicked.connect(self.cancel_extract_archive)
+ self.extract_cancel_button.setEnabled(False) # Initially disabled
+ button_layout.addWidget(self.extract_cancel_button)
+
+ # Create a container widget to center the button layout
+ button_container = QWidget()
+ button_container_layout = QHBoxLayout(button_container)
+ button_container_layout.addStretch()
+ button_container_layout.addLayout(button_layout)
+ button_container_layout.addStretch()
+ single_sizer.addWidget(button_container)
+
+ self.extract_tab_widget.addTab(single_panel, "Single Extract")
+
+ def create_batch_extract_tab(self):
+ """Create batch archive extraction tab"""
+ batch_panel = QWidget()
+ main_layout = QHBoxLayout(batch_panel)
+
+ # Left side - File management and selection
+ left_panel = QWidget()
+ left_layout = QVBoxLayout(left_panel)
+
+ # File selection group with ScrollArea inside GroupBox
+ file_group = QGroupBox("Batch Archive Files")
+ file_group.setMinimumHeight(200) # Set minimum height
+ file_group_layout = QVBoxLayout(file_group)
+
+ # Create ScrollArea inside GroupBox
+ file_scroll_area = ScrollArea()
+ file_scroll_area.setWidgetResizable(True)
+ file_scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+ file_scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+
+ # Create content widget for ScrollArea
+ file_content_widget = QWidget()
+ file_content_layout = QVBoxLayout(file_content_widget)
+
+ # Drag and drop area
+ self.batch_drop_area = BatchDropZoneWidget("Drag archive files here\nor click to browse")
+ file_content_layout.addWidget(self.batch_drop_area)
+
+ # File list
+ self.batch_files_listbox = ListWidget()
+ self.batch_files_listbox.setMinimumHeight(200)
+ self.batch_files_listbox.setMinimumWidth(300)
+ file_content_layout.addWidget(self.batch_files_listbox)
+
+ # File management buttons
+ file_buttons_layout = QHBoxLayout()
+
+ self.batch_add_files_btn = PushButton("Add Files")
+ self.batch_add_files_btn.setMinimumWidth(80)
+ self.batch_add_files_btn.clicked.connect(self.browse_batch_archive_files)
+ file_buttons_layout.addWidget(self.batch_add_files_btn)
+
+ self.batch_remove_files_btn = PushButton("Remove Selected")
+ self.batch_remove_files_btn.setMinimumWidth(100)
+ self.batch_remove_files_btn.clicked.connect(self.remove_selected_batch_files)
+ file_buttons_layout.addWidget(self.batch_remove_files_btn)
+
+ self.batch_clear_files_btn = PushButton("Clear All")
+ self.batch_clear_files_btn.setMinimumWidth(80)
+ self.batch_clear_files_btn.clicked.connect(self.clear_batch_files)
+ file_buttons_layout.addWidget(self.batch_clear_files_btn)
+
+ file_buttons_layout.addStretch()
+ file_content_layout.addLayout(file_buttons_layout)
+
+ # Set content widget to ScrollArea
+ file_scroll_area.setWidget(file_content_widget)
+
+ # Add ScrollArea to GroupBox layout
+ file_group_layout.addWidget(file_scroll_area)
+
+ left_layout.addWidget(file_group)
+ left_layout.addStretch(1)
+
+ # Right side - Configuration and progress
+ right_panel = QWidget()
+ right_layout = QVBoxLayout(right_panel)
+
+ # Destination folder group with ScrollArea inside GroupBox
+ dest_group = QGroupBox("Destination Folder")
+ dest_group.setMinimumHeight(200) # Set minimum height
+ dest_group_layout = QVBoxLayout(dest_group)
+
+ # Create ScrollArea inside GroupBox
+ dest_scroll_area = ScrollArea()
+ dest_scroll_area.setWidgetResizable(True)
+ dest_scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+ dest_scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+
+ # Create content widget for ScrollArea
+ dest_content_widget = QWidget()
+ dest_content_layout = QVBoxLayout(dest_content_widget)
+
+ dest_layout = QHBoxLayout()
+ self.batch_extract_dest_text = LineEdit()
+ setCustomStyleSheet(self.batch_extract_dest_text, CON.qss_line, CON.qss_line)
+ dest_layout.addWidget(self.batch_extract_dest_text, 1)
+
+ batch_dest_button = PushButton("Browse...")
+ batch_dest_button.setMinimumWidth(70)
+ batch_dest_button.clicked.connect(self.browse_batch_extract_dest)
+ dest_layout.addWidget(batch_dest_button)
+
+ dest_content_layout.addLayout(dest_layout)
+
+ # Set content widget to ScrollArea
+ dest_scroll_area.setWidget(dest_content_widget)
+
+ # Add ScrollArea to GroupBox layout
+ dest_group_layout.addWidget(dest_scroll_area)
+
+ right_layout.addWidget(dest_group)
+
+ # Options group with ScrollArea inside GroupBox
+ options_group = QGroupBox("Extract Options")
+ options_group.setMinimumHeight(200) # Set minimum height
+ options_group_layout = QVBoxLayout(options_group)
+
+ # Create ScrollArea inside GroupBox
+ options_scroll_area = ScrollArea()
+ options_scroll_area.setWidgetResizable(True)
+ options_scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+ options_scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+
+ # Create content widget for ScrollArea
+ options_content_widget = QWidget()
+ options_content_layout = QVBoxLayout(options_content_widget)
+
+ options_layout = QHBoxLayout()
+
+ # Left options column
+ left_options = QVBoxLayout()
+ self.batch_create_subfolders_check = CheckBox("Create subfolder for each archive")
+ self.batch_create_subfolders_check.setChecked(True)
+ left_options.addWidget(self.batch_create_subfolders_check)
+
+ # Overwrite options
+ self.batch_overwrite_files_check = CheckBox("Overwrite existing files")
+ self.batch_overwrite_files_check.setChecked(False)
+ left_options.addWidget(self.batch_overwrite_files_check)
+
+ # Skip existing files option
+ self.batch_skip_existing_files_check = CheckBox("Skip existing files")
+ self.batch_skip_existing_files_check.setChecked(False)
+ left_options.addWidget(self.batch_skip_existing_files_check)
+
+ # Overwrite strategy options
+ self.overwrite_strategy_combo = ModelComboBox()
+ self.overwrite_strategy_combo.addItems([
+ "Overwrite all",
+ "Skip existing",
+ "Rename new",
+ "Overwrite if newer"
+ ])
+ self.overwrite_strategy_combo.setMinimumHeight(30)
+ setCustomStyleSheet(self.overwrite_strategy_combo, CON.qss_combo_2, CON.qss_combo_2)
+ left_options.addWidget(QLabel("Overwrite Strategy:"))
+
+ left_options.addWidget(self.overwrite_strategy_combo)
- tab_sizer.addStretch(1) # Push content to top
+ options_layout.addLayout(left_options)
+ options_layout.addStretch(1)
+
+ options_content_layout.addLayout(options_layout)
+
+ # Set content widget to ScrollArea
+ options_scroll_area.setWidget(options_content_widget)
+
+ # Add ScrollArea to GroupBox layout
+ options_group_layout.addWidget(options_scroll_area)
+
+ right_layout.addWidget(options_group)
+
+ # Progress group with ScrollArea inside GroupBox
+ progress_group = QGroupBox("Progress & Statistics")
+ progress_group.setMinimumHeight(200) # Set minimum height
+ progress_group_layout = QVBoxLayout(progress_group)
+
+ # Create ScrollArea inside GroupBox
+ progress_scroll_area = ScrollArea()
+ progress_scroll_area.setWidgetResizable(True)
+ progress_scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+ progress_scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+
+ # Create content widget for ScrollArea
+ progress_content_widget = QWidget()
+ progress_content_layout = QVBoxLayout(progress_content_widget)
+
+ # Progress label
+ self.batch_progress_label = QLabel("Ready to extract archives")
+ self.batch_progress_label.setWordWrap(True)
+ progress_content_layout.addWidget(self.batch_progress_label)
+
+ # Progress bar
+ self.batch_progress = ProgressBar()
+ self.batch_progress.setRange(0, 100)
+ self.batch_progress.setValue(0)
+ progress_content_layout.addWidget(self.batch_progress)
+
+ # Statistics in a grid layout
+ stats_widget = QWidget()
+ stats_layout = QGridLayout(stats_widget)
+ stats_layout.setSpacing(10)
+
+ # Statistics labels
+ self.batch_total_count_label = QLabel("Total Archives:")
+ self.batch_total_count_value = QLabel("0")
+ stats_layout.addWidget(self.batch_total_count_label, 0, 0)
+ stats_layout.addWidget(self.batch_total_count_value, 0, 1)
+
+ self.batch_success_count_label = QLabel("Successful:")
+ self.batch_success_count_value = QLabel("0")
+ stats_layout.addWidget(self.batch_success_count_label, 1, 0)
+ stats_layout.addWidget(self.batch_success_count_value, 1, 1)
+
+ self.batch_failed_count_label = QLabel("Failed:")
+ self.batch_failed_count_value = QLabel("0")
+ stats_layout.addWidget(self.batch_failed_count_label, 2, 0)
+ stats_layout.addWidget(self.batch_failed_count_value, 2, 1)
+
+ progress_content_layout.addWidget(stats_widget)
+
+ # Set content widget to ScrollArea
+ progress_scroll_area.setWidget(progress_content_widget)
+
+ # Add ScrollArea to GroupBox layout
+ progress_group_layout.addWidget(progress_scroll_area)
+
+ right_layout.addWidget(progress_group)
+
+ # Control buttons
+ button_widget = QWidget()
+ button_layout = QHBoxLayout(button_widget)
+ button_layout.setSpacing(10)
+
+ self.batch_start_btn = PrimaryPushButton("Start Extract")
+ self.batch_start_btn.setMinimumWidth(100)
+ self.batch_start_btn.clicked.connect(self.start_batch_extract)
+ button_layout.addWidget(self.batch_start_btn)
+
+ self.batch_stop_btn = PushButton("Stop")
+ self.batch_stop_btn.setMinimumWidth(60)
+ self.batch_stop_btn.clicked.connect(self.stop_batch_extract)
+ self.batch_stop_btn.setEnabled(False)
+ button_layout.addWidget(self.batch_stop_btn)
+
+ button_layout.addStretch(1)
+ right_layout.addWidget(button_widget)
+
+ # Add panels to main layout with proportional sizing
+ main_layout.addWidget(left_panel, 2) # 2/4 of width for file management
+ main_layout.addWidget(right_panel, 2) # 2/4 of width for options and progress - equal width for both panels
+
+ self.extract_tab_widget.addTab(batch_panel, "Batch Extract")
+
+ # Connect drop area signals
+ self.batch_drop_area.files_dropped.connect(self.on_batch_files_dropped)
def create_add_tab(self):
tab_panel = QWidget()
tab_sizer = QVBoxLayout(tab_panel)
self.notebook.addTab(tab_panel, "Add to Archive") # Changed tab title
- # Existing Archive file selection
+ # Existing Archive file selection with ScrollArea inside GroupBox
zip_box = QGroupBox("Existing Archive File") # Changed group box title
- zip_box_sizer = QHBoxLayout(zip_box)
+ zip_box.setMinimumHeight(200) # Set minimum height
+ zip_box_sizer = QVBoxLayout(zip_box)
+
+ # Create ScrollArea inside GroupBox
+ zip_scroll_area = ScrollArea()
+ zip_scroll_area.setWidgetResizable(True)
+ zip_scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+ zip_scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+
+ # Create content widget for ScrollArea
+ zip_content_widget = QWidget()
+ zip_content_layout = QHBoxLayout(zip_content_widget)
self.add_zip_text = LineEdit()
setCustomStyleSheet(self.add_zip_text, CON.qss_line, CON.qss_line)
# self.add_zip_text.setReadOnly(True) # Allow users to manually input path
- zip_box_sizer.addWidget(self.add_zip_text, 1)
+ zip_content_layout.addWidget(self.add_zip_text, 1)
zip_button = PushButton("Browse...")
zip_button.clicked.connect(self.browse_add_archive) # Changed signal
- zip_box_sizer.addWidget(zip_button)
+ zip_content_layout.addWidget(zip_button)
+
+ # Set content widget to ScrollArea
+ zip_scroll_area.setWidget(zip_content_widget)
+
+ # Add ScrollArea to GroupBox layout
+ zip_box_sizer.addWidget(zip_scroll_area)
+
tab_sizer.addWidget(zip_box)
- # File to add selection
+ # File to add selection with ScrollArea inside GroupBox
file_box = QGroupBox("Files to Add")
+ file_box.setMinimumHeight(200) # Set minimum height
file_box_sizer = QVBoxLayout(file_box)
+
+ # Create ScrollArea inside GroupBox
+ file_scroll_area = ScrollArea()
+ file_scroll_area.setWidgetResizable(True)
+ file_scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+ file_scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+
+ # Create content widget for ScrollArea
+ file_content_widget = QWidget()
+ file_content_layout = QVBoxLayout(file_content_widget)
# File list for multiple files (always visible)
self.add_files_listbox = ListWidget()
self.add_files_listbox.setMinimumHeight(150)
self.add_files_listbox.setVisible(True) # Always visible
- file_box_sizer.addWidget(self.add_files_listbox)
+ file_content_layout.addWidget(self.add_files_listbox)
# Browse button
file_button = PushButton("Browse...")
file_button.clicked.connect(self.browse_add_file)
- file_box_sizer.addWidget(file_button)
+ file_content_layout.addWidget(file_button)
+
+ # Set content widget to ScrollArea
+ file_scroll_area.setWidget(file_content_widget)
+
+ # Add ScrollArea to GroupBox layout
+ file_box_sizer.addWidget(file_scroll_area)
tab_sizer.addWidget(file_box)
@@ -516,11 +1270,28 @@ def create_add_tab(self):
self.add_progress.setValue(0)
tab_sizer.addWidget(self.add_progress)
+ # Add button layout with cancel button
+ button_layout = QHBoxLayout()
+ button_layout.setSpacing(10)
+
# Add button
self.add_button = PrimaryPushButton("Add to Archive") # Changed button text
-
self.add_button.clicked.connect(self.start_add_to_archive) # Changed signal
- tab_sizer.addWidget(self.add_button, 0, Qt.AlignmentFlag.AlignCenter)
+ button_layout.addWidget(self.add_button)
+
+ # Cancel button
+ self.add_cancel_button = PushButton("Cancel")
+ self.add_cancel_button.clicked.connect(self.cancel_add_to_archive)
+ self.add_cancel_button.setEnabled(False) # Initially disabled
+ button_layout.addWidget(self.add_cancel_button)
+
+ # Create a container widget to center the button layout
+ button_container = QWidget()
+ button_container_layout = QHBoxLayout(button_container)
+ button_container_layout.addStretch()
+ button_container_layout.addLayout(button_layout)
+ button_container_layout.addStretch()
+ tab_sizer.addWidget(button_container)
tab_sizer.addStretch(1) # Push content to top
@@ -529,18 +1300,36 @@ def create_list_tab(self):
tab_sizer = QVBoxLayout(tab_panel)
self.notebook.addTab(tab_panel, "List Contents")
- # Archive file selection (changed title)
+ # Archive file selection with ScrollArea inside GroupBox (changed title)
zip_box = QGroupBox("Archive File")
- zip_box_sizer = QHBoxLayout(zip_box)
+ zip_box.setMinimumHeight(200) # Set minimum height
+ zip_box_sizer = QVBoxLayout(zip_box)
+
+ # Create ScrollArea inside GroupBox
+ zip_scroll_area = ScrollArea()
+ zip_scroll_area.setWidgetResizable(True)
+ zip_scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+ zip_scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+
+ # Create content widget for ScrollArea
+ zip_content_widget = QWidget()
+ zip_content_layout = QHBoxLayout(zip_content_widget)
self.list_zip_text = LineEdit()
setCustomStyleSheet(self.list_zip_text, CON.qss_line, CON.qss_line)
# self.list_zip_text.setReadOnly(True) # Allow users to manually input path
- zip_box_sizer.addWidget(self.list_zip_text, 1)
+ zip_content_layout.addWidget(self.list_zip_text, 1)
zip_button = PushButton("Browse...")
zip_button.clicked.connect(self.browse_list_archive) # Changed signal
- zip_box_sizer.addWidget(zip_button)
+ zip_content_layout.addWidget(zip_button)
+
+ # Set content widget to ScrollArea
+ zip_scroll_area.setWidget(zip_content_widget)
+
+ # Add ScrollArea to GroupBox layout
+ zip_box_sizer.addWidget(zip_scroll_area)
+
tab_sizer.addWidget(zip_box)
# Password status indicator
@@ -553,25 +1342,60 @@ def create_list_tab(self):
password_status_box.addStretch()
tab_sizer.addLayout(password_status_box)
- # Listbox for contents
+ # Listbox for contents with ScrollArea inside GroupBox
contents_box = QGroupBox("Archive Contents") # Changed group box title
+ contents_box.setMinimumHeight(200) # Set minimum height
contents_box_sizer = QVBoxLayout(contents_box)
+
+ # Create ScrollArea inside GroupBox
+ contents_scroll_area = ScrollArea()
+ contents_scroll_area.setWidgetResizable(True)
+ contents_scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+ contents_scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+
+ # Create content widget for ScrollArea
+ contents_content_widget = QWidget()
+ contents_content_layout = QVBoxLayout(contents_content_widget)
self.contents_listbox = ListWidget()
self.contents_listbox.setMinimumHeight(250) # Set larger minimum height
self.contents_listbox.setDragEnabled(True) # Enable drag functionality
- contents_box_sizer.addWidget(self.contents_listbox, 3) # Increase stretch weight
+ contents_content_layout.addWidget(self.contents_listbox, 3) # Increase stretch weight
# Set right-click menu
self.contents_listbox.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
# Context menu functionality removed
# self.contents_listbox.customContextMenuRequested.connect(self.show_contents_context_menu)
- tab_sizer.addWidget(contents_box, 2) # Give contents box more stretch
+
+ # Set content widget to ScrollArea
+ contents_scroll_area.setWidget(contents_content_widget)
+
+ # Add ScrollArea to GroupBox layout
+ contents_box_sizer.addWidget(contents_scroll_area)
+
+ tab_sizer.addWidget(contents_box, 2) # Give contents group box more stretch
+ # List button layout with cancel button
+ button_layout = QHBoxLayout()
+ button_layout.setSpacing(10)
+
# List button
self.list_button = PrimaryPushButton("List Contents")
-
self.list_button.clicked.connect(self.start_list_archive_contents) # Changed signal
- tab_sizer.addWidget(self.list_button, 0, Qt.AlignmentFlag.AlignCenter)
+ button_layout.addWidget(self.list_button)
+
+ # Cancel button
+ self.list_cancel_button = PushButton("Cancel")
+ self.list_cancel_button.clicked.connect(self.cancel_list_archive_contents)
+ self.list_cancel_button.setEnabled(False) # Initially disabled
+ button_layout.addWidget(self.list_cancel_button)
+
+ # Create a container widget to center the button layout
+ button_container = QWidget()
+ button_container_layout = QHBoxLayout(button_container)
+ button_container_layout.addStretch()
+ button_container_layout.addLayout(button_layout)
+ button_container_layout.addStretch()
+ tab_sizer.addWidget(button_container)
tab_sizer.addStretch(1) # Push content to top
@@ -699,88 +1523,38 @@ def _verify_password_strength(self, password):
# We don't need to verify it against an existing archive since we're creating a new one
return True
- def on_tab_changed(self, index):
- """Handle tab change with optional slide animation effect based on UI_FLUENT environment variable"""
- import sys
- import os
- sys.path.append(os.path.join(os.path.dirname(__file__), 'support'))
- from support.check_flag import check_flag
-
- # Check if UI_FLUENT environment variable is set to YES using check_flag function
- ui_fluent_enabled = check_flag("UI_FLUENT")
-
- # Skip animation if UI_FLUENT is not enabled
- if not ui_fluent_enabled:
- self._previous_tab_index = index
- # Force layout update when animation is disabled
- self.notebook.currentWidget().updateGeometry()
- if self.notebook.currentWidget().layout():
- self.notebook.currentWidget().layout().update()
- self.notebook.currentWidget().layout().activate()
- return
-
- # Proceed with animation if UI_FLUENT is enabled
- from PySide6.QtCore import QPropertyAnimation, QEasingCurve, QRect
+ def confirm_cancel(self, operation_name):
+ """Show confirmation dialog for canceling an operation
- # Get current tab widget
- current_widget = self.notebook.currentWidget()
- if not current_widget:
- return
-
- # Skip animation during initial startup to prevent layout issues
- if not hasattr(self, '_previous_tab_index') and not self.notebook.isVisible():
- self._previous_tab_index = index
- return
+ Args:
+ operation_name: Name of the operation being canceled
- # Get tab widget dimensions
- tab_width = self.notebook.width()
- tab_height = self.notebook.height()
-
- # Skip animation if window is not yet properly sized
- if tab_width <= 0 or tab_height <= 0:
- self._previous_tab_index = index
- return
-
- # Determine slide direction based on tab index
- if hasattr(self, '_previous_tab_index'):
- if index > self._previous_tab_index:
- # Sliding from right to left - start from 80% of width to prevent going out of bounds
- start_pos = QRect(int(tab_width * 0.8), 0, tab_width, tab_height)
- else:
- # Sliding from left to right - start from -80% of width to prevent going out of bounds
- start_pos = QRect(int(-tab_width * 0.8), 0, tab_width, tab_height)
- else:
- # First time, slide from right - start from 80% of width
- start_pos = QRect(int(tab_width * 0.8), 0, tab_width, tab_height)
-
- # Set initial position
- current_widget.setGeometry(start_pos)
-
- # Create slide animation
- self.slide_animation = QPropertyAnimation(current_widget, b"geometry")
- self.slide_animation.setDuration(300) # 300ms animation for smooth slide
- self.slide_animation.setStartValue(start_pos)
- self.slide_animation.setEndValue(QRect(0, 0, tab_width, tab_height))
- self.slide_animation.setEasingCurve(QEasingCurve.Type.OutCubic)
-
- # Store current tab index for next animation
- self._previous_tab_index = index
-
- # Connect animation finished signal to update layout
- self.slide_animation.finished.connect(lambda: self._update_tab_layout(current_widget))
+ Returns:
+ bool: True if user confirmed cancel, False otherwise
+ """
+ reply = QMessageBox.question(
+ self,
+ "取消操作",
+ f"确定要取消当前的{operation_name}操作吗?",
+ QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
+ QMessageBox.StandardButton.No
+ )
+ return reply == QMessageBox.StandardButton.Yes
+
+ def log_cancel(self, operation_type):
+ """Log a cancel operation
- # Start the animation
- self.slide_animation.start()
+ Args:
+ operation_type: Type of operation that was canceled
+ """
+ import datetime
+ timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+ print(f"[{timestamp}] [CANCEL] {operation_type} operation canceled by user")
- def _update_tab_layout(self, widget):
- """Update widget layout after animation completes"""
- # Force layout update to prevent layout issues
- if widget and widget.layout():
- widget.layout().update()
- widget.layout().activate()
- widget.updateGeometry()
- # Repaint the widget to ensure all elements are properly displayed
- widget.repaint()
+ def on_tab_changed(self, index):
+ """Handle tab change without animation"""
+ # Simply store the previous tab index and return
+ self._previous_tab_index = index
@@ -950,6 +1724,10 @@ def start_create_archive(self):
self.create_progress_label.setText("Starting archive creation...")
self.create_progress.setValue(0)
+ # Enable cancel button and disable create button during operation
+ self.create_button.setEnabled(False)
+ self.create_cancel_button.setEnabled(True)
+
self.create_zip_worker = CreateZipWorker(self.create_output_path, self.create_sources, self.create_archive_format, password)
self.create_zip_worker_thread = QThread()
self.create_zip_worker.moveToThread(self.create_zip_worker_thread)
@@ -957,6 +1735,7 @@ def start_create_archive(self):
self.create_zip_worker.finished.connect(self.on_create_archive_finished)
self.create_zip_worker.progress_updated.connect(self.update_create_progress)
self.create_zip_worker.conversion_error.connect(self.on_create_archive_error)
+ self.create_zip_worker.canceled.connect(self.on_create_archive_canceled)
self.create_zip_worker_thread.started.connect(self.create_zip_worker.run)
self.create_zip_worker_thread.start()
@@ -998,8 +1777,43 @@ def on_create_archive_error(self, error_message):
# Update archive status display
self.update_archive_status(archive_info, False)
+ def cancel_create_archive(self):
+ """Cancel the archive creation process"""
+ if self.confirm_cancel("归档创建"):
+ self.log_cancel("Create Archive")
+ # Stop the worker
+ if self.create_zip_worker:
+ self.create_zip_worker.stop()
+
+ def on_create_archive_canceled(self):
+ """Handle archive creation canceled"""
+ # 使用强制线程清理方法
+ self._force_cleanup_create_thread()
+
+ # Reset button states
+ self.create_button.setEnabled(True)
+ self.create_cancel_button.setEnabled(False)
+
+ # Update progress and status
+ self.create_progress.setValue(0)
+ self.create_progress_label.setText("Archive creation canceled")
+
+ # Show cancel notification
+ self._show_info_bar(
+ title='Canceled',
+ content='Archive creation canceled by user',
+ duration=2000
+ )
+
+ # Update archive status display
+ self.update_archive_status("Archive creation canceled", False)
+
def _force_cleanup_create_thread(self):
"""强制清理创建归档的线程,确保完全终止"""
+ # Reset button states
+ self.create_button.setEnabled(True)
+ self.create_cancel_button.setEnabled(False)
+
if self.create_zip_worker_thread:
if self.create_zip_worker_thread.isRunning():
# 先尝试正常退出
@@ -1056,6 +1870,390 @@ def browse_extract_dest(self):
self.extract_dest_path = dir_dialog.selectedFiles()[0]
self.extract_dest_text.setText(self.extract_dest_path)
+ def browse_batch_extract_dest(self):
+ """Browse for batch extraction destination folder"""
+ dir_dialog = QFileDialog(self)
+ dir_dialog.setFileMode(QFileDialog.FileMode.Directory)
+ dir_dialog.setOption(QFileDialog.Option.ShowDirsOnly, True)
+ if dir_dialog.exec():
+ self.batch_extract_dest_path = dir_dialog.selectedFiles()[0]
+ self.batch_extract_dest_text.setText(self.batch_extract_dest_path)
+
+ def browse_batch_archive_files(self):
+ """Browse for archive files to batch extract"""
+ file_dialog = QFileDialog(self)
+ file_dialog.setFileMode(QFileDialog.FileMode.ExistingFiles)
+ file_dialog.setNameFilters([
+ "Archive files (*.zip *.rar *.7z *.tar *.gz *.bz2 *.xz *.tar.gz *.tar.bz2 *.tar.xz *.tgz *.tbz2)",
+ "All files (*)"
+ ])
+ if file_dialog.exec():
+ file_paths = file_dialog.selectedFiles()
+ self.add_batch_files(file_paths)
+
+ def add_batch_files(self, file_paths):
+ """Add files to batch list"""
+ supported_formats = ('.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.xz', '.tar.gz', '.tar.bz2', '.tar.xz', '.tgz', '.tbz2')
+
+ for file_path in file_paths:
+ # Check if file is a supported archive format
+ if os.path.splitext(file_path.lower())[1] in supported_formats:
+ # Avoid duplicates
+ if file_path not in [self.batch_files_listbox.item(i).toolTip() for i in range(self.batch_files_listbox.count())]:
+ item = QListWidgetItem(os.path.basename(file_path))
+ item.setToolTip(file_path)
+ self.batch_files_listbox.addItem(item)
+
+ self.update_batch_stats()
+
+ def remove_selected_batch_files(self):
+ """Remove selected files from batch list"""
+ selected_items = self.batch_files_listbox.selectedItems()
+ if not selected_items:
+ return
+
+ # Remove items in reverse order to maintain correct indices
+ indices = []
+ for item in selected_items:
+ indices.append(self.batch_files_listbox.row(item))
+
+ indices.sort(reverse=True)
+
+ for index in indices:
+ self.batch_files_listbox.takeItem(index)
+
+ self.update_batch_stats()
+
+ def clear_batch_files(self):
+ """Clear all files from batch list"""
+ self.batch_files_listbox.clear()
+ self.update_batch_stats()
+
+ def get_batch_archive_files(self):
+ """Get list of all archive files in batch list"""
+ file_paths = []
+ for i in range(self.batch_files_listbox.count()):
+ item = self.batch_files_listbox.item(i)
+ file_paths.append(item.toolTip())
+ return file_paths
+
+ def on_batch_files_dropped(self, file_paths):
+ """Handle files dropped in batch area"""
+ self.add_batch_files(file_paths)
+
+ def update_batch_stats(self):
+ """Update batch statistics display"""
+ total_count = self.batch_files_listbox.count()
+ self.batch_total_count_label.setText(f"Total: {total_count}")
+ self.batch_success_count_label.setText("Success: 0")
+ self.batch_failed_count_label.setText("Failed: 0")
+
+ def start_batch_extract(self):
+ """Start batch extraction process"""
+ file_paths = self.get_batch_archive_files()
+
+ if not file_paths:
+ self._show_popup(
+ target=self.batch_start_btn,
+ icon=InfoBarIcon.ERROR,
+ title='Error',
+ content='Please add archive files to extract',
+ duration=2000
+ )
+ return
+
+ if not self.batch_extract_dest_path:
+ self._show_popup(
+ target=self.batch_extract_dest_text,
+ icon=InfoBarIcon.ERROR,
+ title='Error',
+ content='Please specify the extraction destination folder',
+ duration=2000
+ )
+ return
+
+ # Validate destination folder
+ if not os.path.exists(self.batch_extract_dest_path):
+ try:
+ os.makedirs(self.batch_extract_dest_path, exist_ok=True)
+ except Exception as e:
+ self._show_popup(
+ target=self.batch_start_btn,
+ icon=InfoBarIcon.ERROR,
+ title='Error',
+ content=f'Failed to create destination folder: {str(e)}',
+ duration=2000
+ )
+ return
+
+ # Create batch extract options
+ create_subfolders = self.batch_create_subfolders_check.isChecked()
+
+ # Get overwrite strategy
+ overwrite_strategy = self.overwrite_strategy_combo.currentText()
+
+ # Determine overwrite behavior based on strategy
+ if overwrite_strategy == "Overwrite all":
+ overwrite_files = True
+ skip_existing = False
+ elif overwrite_strategy == "Skip existing":
+ overwrite_files = False
+ skip_existing = True
+ elif overwrite_strategy == "Rename new":
+ overwrite_files = False
+ skip_existing = False
+ # Note: Rename new functionality would need to be implemented in the archive_manager
+ elif overwrite_strategy == "Overwrite if newer":
+ overwrite_files = True
+ skip_existing = False
+ # Note: Overwrite if newer functionality would need to be implemented in the archive_manager
+ else:
+ # Default behavior
+ overwrite_files = self.batch_overwrite_files_check.isChecked()
+ skip_existing = self.batch_skip_existing_files_check.isChecked()
+
+ # Reset progress and statistics
+ self.batch_progress.setValue(0)
+ self.update_batch_stats()
+ self.batch_progress_label.setText("Preparing for batch extraction...")
+
+ # Disable start button and enable stop button
+ self.batch_start_btn.setEnabled(False)
+ self.batch_stop_btn.setEnabled(True)
+
+ # Create and start batch worker
+ self.batch_extract_worker = BatchExtractWorker(
+ file_paths,
+ self.batch_extract_dest_path,
+ create_subfolders,
+ overwrite_files,
+ parent_gui=self # Pass reference to main GUI for password dialogs
+ )
+ self.batch_extract_worker_thread = QThread()
+ self.batch_extract_worker.moveToThread(self.batch_extract_worker_thread)
+
+ # Connect signals
+ self.batch_extract_worker.finished.connect(self.on_batch_extract_finished)
+ self.batch_extract_worker.progress_updated.connect(self.on_batch_extract_progress)
+ self.batch_extract_worker.conversion_error.connect(self.on_batch_extract_error)
+ self.batch_extract_worker.individual_progress.connect(self.on_batch_individual_progress)
+ self.batch_extract_worker.status_updated.connect(self.on_batch_status_updated)
+ self.batch_extract_worker_thread.started.connect(self.batch_extract_worker.run)
+ self.batch_extract_worker_thread.start()
+
+ def stop_batch_extract(self):
+ """Stop batch extraction process"""
+ if hasattr(self, 'batch_extract_worker'):
+ self.batch_extract_worker.stop()
+ self.batch_progress_label.setText("Stopping batch extraction...")
+ # Disable stop button to prevent multiple stops
+ self.batch_stop_btn.setEnabled(False)
+ # Start a timer to check if thread has stopped after a timeout
+ QTimer.singleShot(2000, self._check_batch_thread_status)
+
+ def _check_batch_thread_status(self):
+ """Check if batch thread has stopped after timeout"""
+ if hasattr(self, 'batch_extract_worker_thread') and self.batch_extract_worker_thread.isRunning():
+ # Thread is still running, try to force stop
+ self.batch_progress_label.setText("Force stopping batch extraction...")
+ self._force_stop_batch_thread()
+ self.on_batch_extract_stopped()
+
+ def _force_stop_batch_thread(self):
+ """Force stop batch extraction thread"""
+ if hasattr(self, 'batch_extract_worker_thread') and self.batch_extract_worker_thread.isRunning():
+ try:
+ # Try to quit gracefully first
+ self.batch_extract_worker_thread.quit()
+ if not self.batch_extract_worker_thread.wait(1000): # Wait 1 second
+ # If graceful quit fails, terminate
+ self.batch_extract_worker_thread.terminate()
+ self.batch_extract_worker_thread.wait(1000) # Wait another second
+ except Exception as e:
+ print(f"Error stopping batch thread: {str(e)}")
+
+ def on_batch_extract_stopped(self):
+ """Handle batch extraction stopped by user"""
+ self.batch_progress_label.setText("Batch extraction stopped by user")
+ self._cleanup_batch_thread()
+ # Update UI
+ self.batch_start_btn.setEnabled(True)
+ self.batch_stop_btn.setEnabled(False)
+
+ def _cleanup_batch_thread(self):
+ """Clean up batch extraction thread and worker resources"""
+ # Clean up worker thread
+ if hasattr(self, 'batch_extract_worker_thread'):
+ if self.batch_extract_worker_thread.isRunning():
+ try:
+ self.batch_extract_worker_thread.quit()
+ self.batch_extract_worker_thread.wait(1000)
+ except Exception as e:
+ print(f"Error cleaning up batch thread: {str(e)}")
+
+ # Delete thread object
+ self.batch_extract_worker_thread.deleteLater()
+ delattr(self, 'batch_extract_worker_thread')
+
+ # Clean up worker
+ if hasattr(self, 'batch_extract_worker'):
+ # Delete worker object
+ self.batch_extract_worker.deleteLater()
+ delattr(self, 'batch_extract_worker')
+
+ def on_batch_extract_progress(self, processed_count, total_count, current_file, success_count, failed_count):
+ """Handle batch extraction progress update"""
+ progress_percentage = int((processed_count / total_count) * 100)
+ self.batch_progress.setValue(progress_percentage)
+
+ # Update statistics
+ self.batch_total_count_value.setText(str(total_count))
+ self.batch_success_count_value.setText(str(success_count))
+ self.batch_failed_count_value.setText(str(failed_count))
+
+ # Update progress label with more detailed information
+ current_file_name = os.path.basename(current_file) if current_file else ""
+ self.batch_progress_label.setText(f"Processing: {current_file_name} ({processed_count}/{total_count}) - {progress_percentage}%")
+
+ def on_batch_individual_progress(self, archive_name, message, progress):
+ """Handle individual archive extraction progress"""
+ # Update status bar with individual file progress
+ self.status_bar.showMessage(f"Extracting {archive_name}: {message} - {progress}%")
+
+ def on_batch_status_updated(self, status_message):
+ """Handle batch extraction status updates"""
+ # Update status bar with general status messages
+ self.status_bar.showMessage(status_message)
+
+ def on_batch_extract_finished(self, success_count, failed_count, success_files=None, failed_files=None):
+ """Handle batch extraction finished"""
+ # Clean up thread
+ self._cleanup_batch_thread()
+
+ # Update final statistics
+ total_count = success_count + failed_count
+ self.batch_total_count_value.setText(str(total_count))
+ self.batch_success_count_value.setText(str(success_count))
+ self.batch_failed_count_value.setText(str(failed_count))
+
+ # Re-enable start button and disable stop button
+ self.batch_start_btn.setEnabled(True)
+ self.batch_stop_btn.setEnabled(False)
+
+ # Show completion message with detailed results
+ result_message = f"Batch extraction completed: {success_count} successful, {failed_count} failed"
+ self.batch_progress_label.setText(result_message)
+
+ # Show appropriate message based on results
+ if failed_count == 0:
+ self._show_info_bar(
+ title='Success',
+ content=f'All {total_count} archives extracted successfully!',
+ duration=3000
+ )
+ elif success_count == 0:
+ self._show_popup(
+ target=self.batch_progress,
+ icon=InfoBarIcon.ERROR,
+ title='Error',
+ content=f'Failed to extract all {total_count} archives.',
+ duration=3000
+ )
+ # Show detailed failures if available
+ if failed_files:
+ self._show_batch_extract_failures(failed_files)
+ else:
+ self._show_info_bar(
+ title='Partially Complete',
+ content=f'Extracted {success_count} out of {total_count} archives successfully.',
+ duration=3000
+ )
+ # Show detailed failures if available
+ if failed_files:
+ self._show_batch_extract_failures(failed_files)
+
+ # Clear status bar
+ self.status_bar.clearMessage()
+
+ def _show_batch_extract_failures(self, failed_files):
+ """Show detailed information about failed extractions"""
+ from qfluentwidgets import MessageBox
+
+ # Create detailed failure message
+ failure_details = "Failed to extract the following archives:\n\n"
+ for file_path, error_msg in failed_files[:10]: # Show first 10 failures
+ file_name = os.path.basename(file_path)
+ failure_details += f"• {file_name}: {error_msg}\n"
+
+ if len(failed_files) > 10:
+ failure_details += f"\n... and {len(failed_files) - 10} more failures."
+
+ # Show message box with failure details
+ msg_box = MessageBox(
+ 'Batch Extraction Failures',
+ failure_details,
+ self
+ )
+ msg_box.yesButton.setText('OK')
+ msg_box.exec()
+
+ def _force_cleanup_batch_thread(self):
+ """Deprecated method, use _cleanup_batch_thread instead"""
+ self._cleanup_batch_thread()
+
+ def on_batch_extract_error(self, error_message):
+ """Handle batch extraction error"""
+ # Clean up thread
+ self._force_cleanup_batch_thread()
+
+ # Re-enable start button and disable stop button
+ self.batch_start_btn.setEnabled(True)
+ self.batch_stop_btn.setEnabled(False)
+
+ self._show_popup(
+ target=self.batch_progress,
+ icon=InfoBarIcon.ERROR,
+ title='Error',
+ content=f'Batch extraction error: {str(error_message)}',
+ duration=3000
+ )
+
+ def on_batch_extract_stopped(self):
+ """Handle batch extraction stopped by user"""
+ # Clean up thread
+ self._force_cleanup_batch_thread()
+
+ # Re-enable start button and disable stop button
+ self.batch_start_btn.setEnabled(True)
+ self.batch_stop_btn.setEnabled(False)
+
+ self.batch_progress_label.setText("Stopped")
+
+ self._show_popup(
+ target=self.batch_progress,
+ icon=InfoBarIcon.WARNING,
+ title='Stopped',
+ content='Batch extraction stopped by user.',
+ duration=2000
+ )
+
+ def _force_cleanup_batch_thread(self):
+ """Force cleanup batch extraction thread"""
+ if hasattr(self, 'batch_extract_worker_thread') and self.batch_extract_worker_thread.isRunning():
+ self.batch_extract_worker_thread.quit()
+ self.batch_extract_worker_thread.wait()
+
+ def reset_batch_ui(self):
+ """Reset batch extraction UI to initial state"""
+ self.batch_files_listbox.clear()
+ self.batch_extract_dest_path = ""
+ self.batch_extract_dest_text.setText("Select destination folder...")
+ self.batch_create_subfolders_check.setChecked(True)
+ self.batch_overwrite_files_check.setChecked(False)
+ self.batch_progress.setValue(0)
+ self.update_batch_stats()
+ self.batch_progress_label.setText("Ready")
+
def auto_set_extract_dest_from_file(self, file_path):
"""Automatically set the extract destination to the file's parent directory"""
try:
@@ -1097,6 +2295,10 @@ def start_extract_archive(self):
self.extract_progress_label.setText("Starting archive extraction...")
self.extract_progress.setValue(0)
+
+ # Enable cancel button and disable extract button during operation
+ self.extract_button.setEnabled(False)
+ self.extract_cancel_button.setEnabled(True)
# Check if archive is password protected by attempting to list contents first
try:
@@ -1148,6 +2350,7 @@ def start_extract_archive(self):
self.extract_zip_worker.progress_updated.connect(self.update_extract_progress)
self.extract_zip_worker.conversion_error.connect(self.on_extract_archive_error)
self.extract_zip_worker.password_required.connect(self.on_extract_archive_error)
+ self.extract_zip_worker.canceled.connect(self.on_extract_archive_canceled)
self.extract_zip_worker_thread.started.connect(self.extract_zip_worker.run)
self.extract_zip_worker_thread.start()
@@ -1206,6 +2409,9 @@ def on_extract_archive_error(self, error_message):
self.extract_zip_worker = ExtractZipWorker(self.extract_zip_path, self.extract_dest_path, password)
self.extract_zip_worker_thread = QThread()
self.extract_zip_worker.moveToThread(self.extract_zip_worker_thread)
+
+ # Connect signals including canceled signal
+ self.extract_zip_worker.canceled.connect(self.on_extract_archive_canceled)
# 连接信号
self.extract_zip_worker.finished.connect(self.on_extract_archive_finished)
@@ -1248,8 +2454,40 @@ def on_extract_archive_error(self, error_message):
)
self.extract_progress_label.setText("Archive extraction failed.")
+ def cancel_extract_archive(self):
+ """Cancel the archive extraction process"""
+ if self.confirm_cancel("归档解压"):
+ self.log_cancel("Extract Archive")
+ # Stop the worker
+ if self.extract_zip_worker:
+ self.extract_zip_worker.stop()
+
+ def on_extract_archive_canceled(self):
+ """Handle archive extraction canceled"""
+ # 使用强制线程清理方法
+ self._force_cleanup_thread()
+
+ # Update progress and status
+ self.extract_progress.setValue(0)
+ self.extract_progress_label.setText("Archive extraction canceled")
+
+ # Show cancel notification
+ self._show_info_bar(
+ title='Canceled',
+ content='Archive extraction canceled by user',
+ duration=2000
+ )
+
+ # Update password status to indicate unknown status
+ self.is_password_protected = False
+ self.update_password_status_extract(False, "Archive Status Unknown")
+
def _force_cleanup_thread(self):
"""强制清理线程,确保完全终止"""
+ # Reset button states
+ self.extract_button.setEnabled(True)
+ self.extract_cancel_button.setEnabled(False)
+
if self.extract_zip_worker_thread:
if self.extract_zip_worker_thread.isRunning():
# 先尝试正常退出
@@ -1339,6 +2577,10 @@ def start_add_to_archive(self):
self.add_progress_label.setText("Starting archive file addition...")
self.add_progress.setValue(0)
+
+ # Enable cancel button and disable add button during operation
+ self.add_button.setEnabled(False)
+ self.add_cancel_button.setEnabled(True)
# Handle multiple files - split by semicolon if contains multiple paths
if isinstance(self.add_file_path, list):
@@ -1357,7 +2599,8 @@ def start_add_to_archive(self):
self.add_to_zip_worker.finished.connect(self.on_add_to_archive_finished)
self.add_to_zip_worker.progress_updated.connect(self.update_add_progress)
- self.add_to_zip_worker.conversion_error.connect(self.on_add_to_archive_error)
+ self.add_to_zip_worker.finished.connect(self.on_add_to_archive_finished)
+ self.add_to_zip_worker.canceled.connect(self.on_add_to_archive_canceled)
self.add_to_zip_worker_thread.started.connect(self.add_to_zip_worker.run)
self.add_to_zip_worker_thread.start()
@@ -1392,8 +2635,36 @@ def on_add_to_archive_error(self, error_message):
)
self.add_progress_label.setText("Archive file addition failed.")
+ def cancel_add_to_archive(self):
+ """Cancel the add to archive process"""
+ if self.confirm_cancel("添加到归档"):
+ self.log_cancel("Add to Archive")
+ # Stop the worker
+ if self.add_to_zip_worker:
+ self.add_to_zip_worker.stop()
+
+ def on_add_to_archive_canceled(self):
+ """Handle add to archive canceled"""
+ # 使用强制线程清理方法
+ self._force_cleanup_add_thread()
+
+ # Update progress and status
+ self.add_progress.setValue(0)
+ self.add_progress_label.setText("Add to archive canceled")
+
+ # Show cancel notification
+ self._show_info_bar(
+ title='Canceled',
+ content='Add to archive canceled by user',
+ duration=2000
+ )
+
def _force_cleanup_add_thread(self):
"""强制清理添加到归档的线程,确保完全终止"""
+ # Reset button states
+ self.add_button.setEnabled(True)
+ self.add_cancel_button.setEnabled(False)
+
if self.add_to_zip_worker_thread:
if self.add_to_zip_worker_thread.isRunning():
# 先尝试正常退出
@@ -1475,6 +2746,10 @@ def start_list_archive_contents(self):
self.contents_listbox.clear()
self.contents_listbox.addItem("Listing contents...")
+
+ # Enable cancel button and disable list button during operation
+ self.list_button.setEnabled(False)
+ self.list_cancel_button.setEnabled(True)
# Reset password protection status
self.is_password_protected = False
@@ -1490,6 +2765,7 @@ def start_list_archive_contents(self):
self.list_zip_worker.finished.connect(self.on_list_zip_finished)
self.list_zip_worker.conversion_error.connect(self.on_list_archive_error)
self.list_zip_worker.password_required.connect(self.on_password_required)
+ self.list_zip_worker.canceled.connect(self.on_list_archive_canceled)
self.list_zip_worker_thread.started.connect(self.list_zip_worker.run)
self.list_zip_worker_thread.start()
@@ -1678,8 +2954,36 @@ def on_list_archive_error(self, error_message):
# Update password status for other errors
self.update_password_status_list(False, "Error Listing Contents")
+ def cancel_list_archive_contents(self):
+ """Cancel the list archive contents process"""
+ if self.confirm_cancel("列出内容"):
+ self.log_cancel("List Contents")
+ # Stop the worker
+ if self.list_zip_worker:
+ self.list_zip_worker.stop()
+
+ def on_list_archive_canceled(self):
+ """Handle list archive contents canceled"""
+ # 使用强制线程清理方法
+ self._force_cleanup_list_thread()
+
+ # Update listbox and status
+ self.contents_listbox.clear()
+ self.contents_listbox.addItem("List contents canceled")
+
+ # Show cancel notification
+ self._show_info_bar(
+ title='Canceled',
+ content='List contents canceled by user',
+ duration=2000
+ )
+
def _force_cleanup_list_thread(self):
"""强制清理列出归档内容的线程,确保完全终止"""
+ # Reset button states
+ self.list_button.setEnabled(True)
+ self.list_cancel_button.setEnabled(False)
+
if self.list_zip_worker_thread:
if self.list_zip_worker_thread.isRunning():
# 先尝试正常退出
diff --git a/build_project_arm64.py b/build_project_arm64.py
index d2127b2..4852cc9 100644
--- a/build_project_arm64.py
+++ b/build_project_arm64.py
@@ -2,7 +2,7 @@
import clean
import buildzip
from setup_ccache import setup_ccache
-target="com.pyquick.converter"
+target="com.intsant.converter"
from patch import enable
from plistedit import add_utf_info
if __name__ == "__main__":
diff --git a/build_project_base.py b/build_project_base.py
index 58570da..9b9bb6b 100644
--- a/build_project_base.py
+++ b/build_project_base.py
@@ -53,7 +53,7 @@ def compile_gui():
"--include-package=image_converter",
"--include-package=support",
"--include-package=update", # 添加update包以确保所有更新功能正常工作
- "--macos-signed-app-name=com.pyquick.converter",
+ "--macos-signed-app-name=com.intsant.converter",
"--enable-plugin=pyside6",
"--prefer-source-code",
"--output-dir=dist", # Output directory
@@ -227,7 +227,7 @@ def create_macos_app_bundle(current_dir, dist_path):
CFBundleIconFile
AppIcon.icns
CFBundleIdentifier
- com.pyquick.converter
+ com.intsant.converter
CFBundleInfoDictionaryVersion
6.0
CFBundleName
diff --git a/debug/debug_gui.py b/debug/debug_gui.py
index 9e049f9..9c47a27 100644
--- a/debug/debug_gui.py
+++ b/debug/debug_gui.py
@@ -5,187 +5,231 @@
import os
import sys
-from PySide6.QtWidgets import (
- QWidget, QVBoxLayout, QHBoxLayout, QLabel,
- QCheckBox, QGroupBox, QSpacerItem, QSizePolicy,
- QPushButton, QTextBrowser
-)
+from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout
from PySide6.QtCore import QSettings, Qt
from PySide6.QtGui import QFont
from qfluentwidgets import (
- CheckBox, TextBrowser, IndeterminateProgressBar,
- ProgressBar, PrimaryPushButton
+ SettingCardGroup, SwitchSettingCard, PushSettingCard, PrimaryPushSettingCard,
+ FluentIcon, BodyLabel, CaptionLabel, TextBrowser, InfoBar, InfoBarPosition,
+ setCustomStyleSheet, HeaderCardWidget, SingleDirectionScrollArea
)
import sys
import os
-from qfluentwidgets import *
-# Add support directory to path for debug_logger import
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'support'))
from support.debug_logger import DebugLogger
-class DebugSettingsWidget(QWidget):
- """Debug Settings GUI Widget"""
-
- def __init__(self):
- super().__init__()
- self.setWindowTitle("Debug Settings")
- self.debug_logger = DebugLogger()
- self.settings = QSettings("MyCompany", "ConverterApp")
+class DebugStatusCard(HeaderCardWidget):
+ """Debug status card using HeaderCardWidget"""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setTitle('Debug Status')
- self.init_ui()
- self.load_settings()
- self.connect_auto_save_signals()
-
- def init_ui(self):
- """Initialize the UI"""
- main_layout = QVBoxLayout(self)
- main_layout.setContentsMargins(25, 25, 25, 25)
- main_layout.setSpacing(20)
-
- # Debug Settings Group
- debug_group = QGroupBox("Debug Settings")
- debug_layout = QVBoxLayout()
- debug_layout.setContentsMargins(25, 25, 25, 25)
- debug_layout.setSpacing(20)
-
- # Add top spacing
- debug_layout.addSpacerItem(QSpacerItem(0, 15, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed))
-
- # Debug Mode Toggle
- self.debug_enabled_checkbox = CheckBox("Enable Debug Mode")
- self.debug_enabled_checkbox.setMinimumHeight(60)
- # Signal connection will be done in connect_auto_save_signals
- debug_layout.addWidget(self.debug_enabled_checkbox)
-
- # Enhanced Logging
- self.enhanced_logging_checkbox = CheckBox("Enable Enhanced Logging (with module info)")
- self.enhanced_logging_checkbox.setMinimumHeight(60)
- # Signal connection will be done in connect_auto_save_signals
- debug_layout.addWidget(self.enhanced_logging_checkbox)
-
- # Log File Info
- log_info_layout = QHBoxLayout()
- log_info_layout.addWidget(QLabel("Log Directory:"))
- self.log_dir_label = QLabel("~/.converter/log/")
- self.log_dir_label.setWordWrap(True)
- self.log_dir_label.setStyleSheet("QLabel { color: #666; }")
- log_info_layout.addWidget(self.log_dir_label)
- log_info_layout.addStretch()
- debug_layout.addLayout(log_info_layout)
-
- # Status Label
- self.status_label = QLabel("Debug mode is currently disabled.")
- self.status_label.setMinimumHeight(60)
- self.status_label.setMinimumWidth(550)
- self.status_label.setWordWrap(True)
- self.status_label.setStyleSheet("""
- QLabel {
- padding: 8px;
- background-color: #f8f9fa;
- border-radius: 16px;
- border: 1px solid #e9ecef;
- }
- """)
- debug_layout.addWidget(self.status_label)
-
- # Log Preview Area
- self.log_preview_browser = TextBrowser()
- self.log_preview_browser.setMinimumHeight(150)
- self.log_preview_browser.setPlaceholderText("Log preview will appear here when debug mode is enabled...")
- debug_layout.addWidget(self.log_preview_browser)
-
- # Buttons Container
- button_container = QHBoxLayout()
- button_container.setSpacing(15)
- from con import CON
- # Test Debug Button
- self.test_debug_button = PrimaryPushButton("Test Debug Output")
- setCustomStyleSheet(self.test_debug_button, CON.qss_debug, CON.qss_debug)
- self.test_debug_button.setFixedSize(180, 60)
- self.test_debug_button.clicked.connect(self.test_debug_output)
-
- # View Logs Button
- self.view_logs_button = PrimaryPushButton("View Log Directory")
- self.view_logs_button.setFixedSize(180, 60)
- setCustomStyleSheet(self.view_logs_button, CON.qss_debug, CON.qss_debug)
- self.view_logs_button.clicked.connect(self.view_log_directory)
-
- # Clear Logs Button
- self.clear_logs_button = PrimaryPushButton("Clear Logs")
- self.clear_logs_button.setFixedSize(180, 60)
- setCustomStyleSheet(self.clear_logs_button, CON.qss_debug, CON.qss_debug)
- self.clear_logs_button.clicked.connect(self.clear_logs)
-
- button_container.addStretch()
- button_container.addWidget(self.test_debug_button)
- button_container.addWidget(self.view_logs_button)
- button_container.addWidget(self.clear_logs_button)
- button_container.addStretch()
- debug_layout.addLayout(button_container)
-
- # Add bottom spacing
- debug_layout.addSpacerItem(QSpacerItem(0, 15, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed))
-
- debug_group.setLayout(debug_layout)
- main_layout.addWidget(debug_group)
-
- self.setLayout(main_layout)
-
- # Set button fonts
- font = QFont()
- font.setPointSize(12)
- font.setBold(True)
- self.test_debug_button.setFont(font)
- self.view_logs_button.setFont(font)
- self.clear_logs_button.setFont(font)
-
- def load_settings(self):
- """Load current debug settings"""
- debug_enabled = bool(self.settings.value("debug_enabled", False, type=bool))
- enhanced_logging = bool(self.settings.value("enhanced_logging", True, type=bool))
+ from qfluentwidgets import IconWidget, HyperlinkLabel
- self.debug_enabled_checkbox.setChecked(debug_enabled)
- self.enhanced_logging_checkbox.setChecked(enhanced_logging)
+ # Create status icon
+ self.statusIcon = IconWidget(FluentIcon.INFO, self)
+ self.statusIcon.setFixedSize(16, 16)
- self.update_status_label()
-
- def update_status_label(self):
- """Update the status label based on current settings"""
- debug_enabled = self.debug_enabled_checkbox.isChecked()
- enhanced_logging = self.enhanced_logging_checkbox.isChecked()
+ # Create status label
+ self.statusLabel = BodyLabel('Debug mode is currently disabled.', self)
+
+ # Create detail button
+ self.detailButton = HyperlinkLabel('View Logs', self)
+ self.detailButton.clicked.connect(self.view_logs)
+
+ # Setup layout
+ self.vBoxLayout = QVBoxLayout()
+ self.hBoxLayout = QHBoxLayout()
+
+ self.hBoxLayout.setSpacing(10)
+ self.vBoxLayout.setSpacing(16)
+ self.hBoxLayout.setContentsMargins(0, 0, 0, 0)
+ self.vBoxLayout.setContentsMargins(0, 0, 0, 0)
+
+ self.hBoxLayout.addWidget(self.statusIcon)
+ self.hBoxLayout.addWidget(self.statusLabel)
+ self.vBoxLayout.addLayout(self.hBoxLayout)
+ self.vBoxLayout.addWidget(self.detailButton)
+ self.viewLayout.addLayout(self.vBoxLayout)
+
+ self.update_status(False, False)
+
+ def update_status(self, debug_enabled, enhanced_logging):
+ """Update status display based on debug settings"""
if debug_enabled:
- status_text = "✓ Debug mode is ENABLED"
+ from qfluentwidgets import InfoBarIcon
+ self.statusIcon.setIcon(InfoBarIcon.SUCCESS)
+ status_text = " Debug mode is ENABLED"
if enhanced_logging:
status_text += " with enhanced logging"
status_text += ". All debug output is being logged to ~/.converter/log/"
- self.status_label.setStyleSheet("""
- QLabel {
- padding: 8px;
- background-color: #e8f5e8;
- border-radius: 16px;
- border: 1px solid #d4edda;
+ self.statusLabel.setStyleSheet("""
+ BodyLabel {
color: #155724;
}
""")
else:
+ self.statusIcon.setIcon(FluentIcon.INFO)
status_text = "Debug mode is DISABLED. Only basic console output will be shown."
- self.status_label.setStyleSheet("""
- QLabel {
- padding: 8px;
- background-color: #f8f9fa;
- border-radius: 16px;
- border: 1px solid #e9ecef;
+ self.statusLabel.setStyleSheet("""
+ BodyLabel {
color: #6c757d;
}
""")
- self.status_label.setText(status_text)
-
+ self.statusLabel.setText(status_text)
+
+ def view_logs(self):
+ """View log directory"""
+ log_dir = os.path.expanduser("~/.converter/log")
+
+ if not os.path.exists(log_dir):
+ os.makedirs(log_dir, exist_ok=True)
+
+ try:
+ if sys.platform == "darwin": # macOS
+ os.system(f"open '{log_dir}'")
+ elif sys.platform == "win32": # Windows
+ os.system(f"explorer '{log_dir}'")
+ else: # Linux
+ os.system(f"xdg-open '{log_dir}'")
+ except Exception as e:
+ print(f"Failed to open log directory: {e}")
+class DebugSettingsWidget(QWidget):
+ """Debug settings widget using qfluentwidgets SettingCard components"""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.debug_logger = DebugLogger()
+ self.settings = QSettings("MyCompany", "ConverterApp")
+ self.setup_ui()
+ self.connect_signals()
+ self.load_settings()
+
+ def setup_ui(self):
+ """Setup the UI layout"""
+ main_layout = QVBoxLayout(self)
+ main_layout.setContentsMargins(0, 0, 0, 0)
+ main_layout.setSpacing(0)
+
+ # Create scroll area
+ self.scroll_area = SingleDirectionScrollArea(orient=Qt.Orientation.Vertical)
+ self.scroll_area.setWidgetResizable(True)
+ self.scroll_area.enableTransparentBackground()
+
+ # Create scroll content widget
+ scroll_content = QWidget()
+ scroll_content.setObjectName("scroll_content")
+ scroll_layout = QVBoxLayout(scroll_content)
+ scroll_layout.setContentsMargins(30, 30, 30, 30)
+ scroll_layout.setSpacing(20)
+
+ # Create setting card groups
+ self.debug_group = SettingCardGroup("Debug Configuration", scroll_content)
+ self.log_group = SettingCardGroup("Log Management", scroll_content)
+
+ # Create debug setting cards
+ self.debug_enabled_card = SwitchSettingCard(
+ FluentIcon.DEVELOPER_TOOLS,
+ "Enable Debug Mode",
+ "Enable debug logging for troubleshooting",
+ parent=self.debug_group
+ )
+
+ self.enhanced_logging_card = SwitchSettingCard(
+ FluentIcon.DOCUMENT,
+ "Enhanced Logging",
+ "Include module information in debug output",
+ parent=self.debug_group
+ )
+
+ # Create log management cards
+ self.test_debug_card = PrimaryPushSettingCard(
+ "Test Debug Output",
+ FluentIcon.CODE,
+ "Generate test debug messages"
+ )
+ self.test_debug_card.clicked.connect(self.test_debug_output)
+
+ self.view_logs_card = PushSettingCard(
+ "View Log Directory",
+ FluentIcon.FOLDER,
+ "Open log folder in file explorer"
+ )
+ self.view_logs_card.clicked.connect(self.view_log_directory)
+
+ self.clear_logs_card = PushSettingCard(
+ "Clear Logs",
+ FluentIcon.DELETE,
+ "Remove all log files"
+ )
+ self.clear_logs_card.clicked.connect(self.clear_logs)
+
+ # Add cards to groups
+ self.debug_group.addSettingCards([
+ self.debug_enabled_card,
+ self.enhanced_logging_card
+ ])
+
+ self.log_group.addSettingCards([
+ self.test_debug_card,
+ self.view_logs_card,
+ self.clear_logs_card
+ ])
+
+ # Add log info and preview to log group
+ log_info_label = CaptionLabel("Log files are stored in ~/.converter/log/")
+ self.log_group.vBoxLayout.addWidget(log_info_label)
+
+ # Debug status card
+ self.debug_status_card = DebugStatusCard(scroll_content)
+
+ # Log preview area
+ self.log_preview_browser = TextBrowser()
+ self.log_preview_browser.setMinimumHeight(150)
+ self.log_preview_browser.setPlaceholderText("Log preview will appear here when debug mode is enabled...")
+ self.log_group.vBoxLayout.addWidget(self.log_preview_browser)
+
+ # Add groups to scroll layout
+ scroll_layout.addWidget(self.debug_group)
+ scroll_layout.addWidget(self.debug_status_card)
+ scroll_layout.addWidget(self.log_group)
+ scroll_layout.addStretch()
+
+ # Set scroll content
+ self.scroll_area.setWidget(scroll_content)
+
+ # Add scroll area to main layout
+ main_layout.addWidget(self.scroll_area)
+
+ def connect_signals(self):
+ """Connect signals for auto-save"""
+ self.debug_enabled_card.checkedChanged.connect(self.on_debug_setting_changed)
+ self.enhanced_logging_card.checkedChanged.connect(self.on_enhanced_logging_changed)
+
+ def load_settings(self):
+ """Load current debug settings"""
+ self.debug_enabled_card.setChecked(bool(self.settings.value("debug_enabled", False, type=bool)))
+ self.enhanced_logging_card.setChecked(bool(self.settings.value("enhanced_logging", True, type=bool)))
+
+ # Disable enhanced logging checkbox if debug mode is not enabled
+ self.enhanced_logging_card.setEnabled(self.debug_enabled_card.isChecked())
+
+ self.update_status_label()
+
+ def update_status_label(self):
+ """Update the status label based on current settings"""
+ debug_enabled = self.debug_enabled_card.isChecked()
+ enhanced_logging = self.enhanced_logging_card.isChecked()
+
+ self.debug_status_card.update_status(debug_enabled, enhanced_logging)
+
def test_debug_output(self):
"""Test debug output functionality"""
self.debug_logger.log_debug("This is a test debug message from Debug Settings GUI")
@@ -208,9 +252,9 @@ def test_debug_output(self):
self.log_preview_browser.setPlainText(preview)
except Exception as e:
self.log_preview_browser.setPlainText(f"Error reading log file: {e}")
-
+
def view_log_directory(self):
- """Open the log directory in file explorer"""
+ """Open log directory in file explorer"""
log_dir = os.path.expanduser("~/.converter/log")
if not os.path.exists(log_dir):
@@ -225,7 +269,7 @@ def view_log_directory(self):
os.system(f"xdg-open '{log_dir}'")
except Exception as e:
self.debug_logger.log_error(f"Failed to open log directory: {e}")
-
+
def clear_logs(self):
"""Clear all log files"""
log_dir = os.path.expanduser("~/.converter/log")
@@ -241,24 +285,25 @@ def clear_logs(self):
except Exception as e:
self.log_preview_browser.setPlainText(f"Error clearing logs: {e}")
- # Use print instead of log_error to avoid potential recursion
print(f"ERROR: Failed to clear logs: {e}")
else:
self.log_preview_browser.setPlainText("Log directory does not exist.")
- def connect_auto_save_signals(self):
- """Connect all UI controls to auto-save functionality"""
- # Connect checkboxes to auto-save - the toggle methods already call auto_save_settings
- # But we also add a direct connection to ensure the signal is triggered
- self.debug_enabled_checkbox.stateChanged.connect(self.on_debug_setting_changed)
- self.enhanced_logging_checkbox.stateChanged.connect(self.on_enhanced_logging_changed)
-
def on_debug_setting_changed(self):
"""Handle debug setting change and trigger auto-save"""
- debug_enabled = self.debug_enabled_checkbox.isChecked()
+ debug_enabled = self.debug_enabled_card.isChecked()
self.settings.setValue("debug_enabled", debug_enabled)
self.settings.sync()
+ # Enable/disable enhanced logging checkbox based on debug mode
+ self.enhanced_logging_card.setEnabled(debug_enabled)
+
+ # If debug mode is disabled, also disable enhanced logging
+ if not debug_enabled:
+ self.enhanced_logging_card.setChecked(False)
+ self.settings.setValue("enhanced_logging", False)
+ self.settings.sync()
+
# Reinitialize debug logger with new settings
self.debug_logger = DebugLogger()
@@ -268,13 +313,10 @@ def on_debug_setting_changed(self):
self.debug_logger.log_info("Debug mode enabled via GUI (auto-save)")
else:
self.debug_logger.log_info("Debug mode disabled via GUI (auto-save)")
-
- # Emit auto-save signal to parent if exists
- self.auto_save_settings()
def on_enhanced_logging_changed(self):
"""Handle enhanced logging setting change and trigger auto-save"""
- enhanced_logging = self.enhanced_logging_checkbox.isChecked()
+ enhanced_logging = self.enhanced_logging_card.isChecked()
self.settings.setValue("enhanced_logging", enhanced_logging)
self.settings.sync()
@@ -284,17 +326,6 @@ def on_enhanced_logging_changed(self):
self.debug_logger.log_info("Enhanced logging enabled via GUI (auto-save)")
else:
self.debug_logger.log_info("Enhanced logging disabled via GUI (auto-save)")
-
- # Emit auto-save signal to parent if exists
- self.auto_save_settings()
-
- def auto_save_settings(self):
- """Auto-save settings"""
- try:
- # Force sync settings to disk
- self.settings.sync()
- except Exception as e:
- print(f"Error in auto_save_settings: {e}")
if __name__ == "__main__":
@@ -307,4 +338,4 @@ def auto_save_settings(self):
widget.resize(800, 600)
widget.show()
- sys.exit(app.exec())
\ No newline at end of file
+ sys.exit(app.exec())
diff --git a/image_converter.py b/image_converter.py
index 4f77733..fd79a42 100644
--- a/image_converter.py
+++ b/image_converter.py
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
-PNG to ICNS Converter with wxPython GUI
+PNG to ICNS Converter with PySide6
This script provides a graphical interface for converting PNG images to ICNS format.
"""
@@ -11,12 +11,13 @@
import os
import threading
import subprocess
+from concurrent.futures import ThreadPoolExecutor, as_completed
from PIL import Image
from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
- QLabel,
+ QLabel, QComboBox, QSpinBox, QListWidget,
QFileDialog, QMessageBox, QTabWidget, QGroupBox, QSizePolicy,
- QTreeWidgetItem, QFrame, QScrollArea, QListWidgetItem
+ QTreeWidgetItem, QFrame,QListWidgetItem
)
from PySide6.QtGui import QPixmap, QIcon, QFont, QImage, QPalette
from PySide6.QtCore import Qt, QSize, Signal, QObject, QThread
@@ -28,8 +29,10 @@
# Add the current directory to Python path to import convert module
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from support import convert
+from support.GUI.image_support import DropZoneWidget, DirectoryDropLineEdit, PreviewTab, ThumbnailGridWidget
from con import CON
+
class ConversionWorker(QObject):
finished = Signal()
progress_updated = Signal(str, int)
@@ -53,6 +56,7 @@ def __init__(self, input_path, output_path, output_format, min_size_param=None,
def run(self):
try:
if self.output_format == "icns":
+ # For icns format, use positional arguments with correct order
convert.convert_image(
self.input_path,
self.output_path,
@@ -63,21 +67,262 @@ def run(self):
progress_callback=self._update_progress_callback
)
else:
+ # For non-icns formats, use keyword arguments for clarity and to avoid parameter order issues
convert.convert_image(
- self.input_path,
- self.output_path,
- self.output_format,
+ input_path=self.input_path,
+ output_path=self.output_path,
+ output_format=self.output_format,
quality=self.quality,
progress_callback=self._update_progress_callback
)
self.finished.emit()
except Exception as e:
- self.conversion_error.emit(str(e))
-
- def _update_progress_callback(self, message, percentage):
+ error_msg = f"Conversion error: {str(e)}"
+ self.conversion_error.emit(error_msg)
+ print(f"[ERROR] ConversionWorker: {error_msg}")
+
+ def _update_progress_callback(self, *args):
+ """Handle variable number of arguments from progress_callback
+ Can be called with:
+ - (message, percentage)
+ - (current, total, message)
+ - (message, percentage, extra)
+ """
+ # Determine the arguments format
+ if len(args) == 2:
+ # Format: (message, percentage)
+ message, percentage = args
+ elif len(args) == 3:
+ # Format: (current, total, message) or (message, percentage, extra)
+ if isinstance(args[0], (int, float)) and isinstance(args[1], (int, float)):
+ # Format: (current, total, message)
+ current, total, message = args
+ percentage = int((current / total) * 100) if total > 0 else 0
+ else:
+ # Format: (message, percentage, extra)
+ message, percentage, _ = args
+ else:
+ # Unexpected format, use default values
+ message = "Processing..." if args else "Unknown progress"
+ percentage = 0
+
self.progress_updated.emit(message, percentage)
+class BatchConversionWorker(QObject):
+ finished = Signal()
+ progress_updated = Signal(int, int, str, int) # current_index, total_count, current_file, percentage
+ file_processed = Signal(str, str, str, bool, str) # filename, input_path, output_path, success, error_message
+ batch_error = Signal(str)
+ total_progress_updated = Signal(int) # overall progress percentage
+
+ def __init__(self, input_paths, output_dir, output_format, min_size_param=None, max_size_param=None, quality_param=None,
+ preserve_folder_structure=False, prefix="", suffix="", auto_detect_max_size=False):
+ super().__init__()
+ self.input_paths = input_paths
+ self.output_dir = output_dir
+ self.output_format = output_format
+ self.quality = int(quality_param) if quality_param is not None else 85
+ self.is_cancelled = False
+ self.preserve_folder_structure = preserve_folder_structure
+ self.prefix = prefix
+ self.suffix = suffix
+ self.auto_detect_max_size = auto_detect_max_size
+
+ if output_format == "icns":
+ self.min_size = int(min_size_param) if min_size_param is not None else 16
+ self.max_size = int(max_size_param) if max_size_param is not None else None
+ else:
+ self.min_size = None
+ self.max_size = None
+
+ def cancel(self):
+ """Cancel the batch conversion process"""
+ self.is_cancelled = True
+
+ def run(self):
+ try:
+ total_files = len(self.input_paths)
+ if total_files == 0:
+ self.finished.emit()
+ return
+
+ # Get common parent directory if preserving folder structure
+ common_parent = None
+ if self.preserve_folder_structure and self.input_paths:
+ # Get all directories
+ directories = [os.path.dirname(path) for path in self.input_paths]
+ if directories:
+ # Find common parent directory
+ common_parent = os.path.commonpath(directories)
+
+ # Calculate optimal number of threads (use CPU cores * 2 for I/O bound tasks)
+ max_workers = min(16, os.cpu_count() * 2)
+
+ # Track processed files and progress
+ processed_files = 0
+
+ # Create a list to hold conversion tasks
+ conversion_tasks = []
+
+ # Prepare all conversion tasks
+ for i, input_path in enumerate(self.input_paths):
+ filename = os.path.basename(input_path)
+ name_without_ext = os.path.splitext(filename)[0]
+
+ # Apply prefix and suffix
+ output_filename = f"{self.prefix}{name_without_ext}{self.suffix}.{self.output_format.lower()}"
+
+ # Determine output path based on folder structure option
+ if self.preserve_folder_structure and common_parent:
+ # Get relative path from common parent
+ relative_dir = os.path.relpath(os.path.dirname(input_path), common_parent)
+ output_path = os.path.join(self.output_dir, relative_dir, output_filename)
+ # Create directories if they don't exist
+ os.makedirs(os.path.dirname(output_path), exist_ok=True)
+ else:
+ # Create "converted" subdirectory in the output directory
+ converted_dir = os.path.join(self.output_dir, "converted")
+ os.makedirs(converted_dir, exist_ok=True)
+ output_path = os.path.join(converted_dir, output_filename)
+
+ # Add to conversion tasks
+ conversion_tasks.append({
+ 'index': i,
+ 'input_path': input_path,
+ 'output_path': output_path,
+ 'filename': filename,
+ 'total_files': total_files
+ })
+
+ # Use ThreadPoolExecutor for concurrent conversion
+ with ThreadPoolExecutor(max_workers=max_workers) as executor:
+ # Submit all tasks
+ future_to_task = {}
+ for task in conversion_tasks:
+ if self.is_cancelled:
+ error_msg = "Batch conversion was cancelled"
+ self.batch_error.emit(error_msg)
+ print(f"[ERROR] BatchConversionWorker: {error_msg}")
+ return
+
+ try:
+ future = executor.submit(self._convert_single_file, task)
+ future_to_task[future] = task
+ except Exception as e:
+ error_msg = f"Error submitting task for {task['filename']}: {str(e)}"
+ self.batch_error.emit(error_msg)
+ print(f"[ERROR] BatchConversionWorker: {error_msg}")
+ # Continue with other tasks instead of failing completely
+
+ # Process completed tasks
+ for future in as_completed(future_to_task):
+ if self.is_cancelled:
+ error_msg = "Batch conversion was cancelled"
+ self.batch_error.emit(error_msg)
+ print(f"[ERROR] BatchConversionWorker: {error_msg}")
+ executor.shutdown(wait=False, cancel_futures=True)
+ return
+
+ task = future_to_task[future]
+ try:
+ success, message = future.result()
+ # Signal that this file was processed
+ self.file_processed.emit(task['filename'], task['input_path'], task['output_path'], success, message if not success else "")
+ except Exception as e:
+ # Signal that this file failed
+ error_msg = f"Error processing {task['filename']}: {str(e)}"
+ self.file_processed.emit(task['filename'], task['input_path'], task['output_path'], False, error_msg)
+ print(f"[ERROR] BatchConversionWorker: {error_msg}")
+
+ # Update processed count and overall progress
+ processed_files += 1
+ overall_progress = int((processed_files / total_files) * 100)
+ self.total_progress_updated.emit(overall_progress)
+
+ # Final progress update
+ self.total_progress_updated.emit(100)
+ self.finished.emit()
+
+ except Exception as e:
+ error_msg = f"Batch conversion error: {str(e)}"
+ self.batch_error.emit(error_msg)
+ print(f"[ERROR] BatchConversionWorker: {error_msg}")
+
+ def _convert_single_file(self, task):
+ """Convert a single file (thread-safe)"""
+ if self.is_cancelled:
+ raise Exception("Batch conversion was cancelled")
+
+ # Update file-specific progress
+ self.progress_updated.emit(task['index']+1, task['total_files'], task['filename'], 0)
+
+ # Create a progress callback for this specific file
+ def progress_callback(*args):
+ """Handle variable number of arguments from progress_callback
+ Can be called with:
+ - (message, percentage)
+ - (current, total, message)
+ - (message, percentage, extra)
+ """
+ # Determine the arguments format
+ if len(args) == 2:
+ # Format: (message, percentage)
+ message, percentage = args
+ elif len(args) == 3:
+ # Format: (current, total, message) or (message, percentage, extra)
+ if isinstance(args[0], (int, float)) and isinstance(args[1], (int, float)):
+ # Format: (current, total, message)
+ current, total, message = args
+ percentage = int((current / total) * 100) if total > 0 else 0
+ else:
+ # Format: (message, percentage, extra)
+ message, percentage, _ = args
+ else:
+ # Unexpected format, use default values
+ message = "Processing..." if args else "Unknown progress"
+ percentage = 0
+
+ # Update the file-specific progress
+ self.progress_updated.emit(task['index']+1, task['total_files'], task['filename'], percentage)
+
+ # Perform the conversion
+ if self.output_format == "icns":
+ # Auto-detect max size if enabled
+ current_max_size = int(self.max_size) if self.max_size is not None else None
+ if self.auto_detect_max_size:
+ try:
+ # Get image dimensions
+ width, height = convert.get_image_info(task['input_path'])
+ # Use the minimum of width and height as the max size (since ICNS uses square sizes)
+ current_max_size = min(width, height)
+ except Exception as e:
+ # Fall back to default if auto-detect fails
+ print(f"[WARNING] Failed to auto-detect max size for {task['filename']}: {str(e)}")
+ current_max_size = int(self.max_size) if self.max_size is not None else None
+
+ success, message = convert.convert_image(
+ task['input_path'],
+ task['output_path'],
+ self.output_format,
+ int(self.min_size) if self.min_size is not None else 16,
+ current_max_size,
+ quality=self.quality,
+ progress_callback=progress_callback
+ )
+ else:
+ # For non-icns formats, use keyword arguments for clarity and to avoid parameter order issues
+ success, message = convert.convert_image(
+ input_path=task['input_path'],
+ output_path=task['output_path'],
+ output_format=self.output_format,
+ quality=self.quality,
+ progress_callback=progress_callback
+ )
+
+ return success, message
+
+
class ICNSConverterGUI(QMainWindow):
def _load_qss_file(self, filename):
@@ -139,12 +384,13 @@ def closeEvent(self, e):
def _apply_theme(self, is_dark_mode):
if is_dark_mode:
self.setStyleSheet(self.DARK_QSS)
-
-
else:
self.setStyleSheet(self.LIGHT_QSS)
-
+ # Update DropZoneWidget theme if it exists
+ if hasattr(self, 'drop_zone') and self.drop_zone:
+ self.drop_zone.set_theme(is_dark_mode)
+
# Update success view theme if it exists and is visible
if hasattr(self, 'success_widget') and self.success_widget and self.success_widget.isVisible():
self._apply_success_theme()
@@ -171,10 +417,12 @@ def load_settings(self):
auto_preview_val = settings.value("image_converter/auto_preview", True, type=bool)
remember_path_val = settings.value("image_converter/remember_path", True, type=bool)
completion_notify_val = settings.value("image_converter/completion_notify", True, type=bool)
+ task_mode_val = settings.value("task_mode", False, type=bool)
self.auto_preview = bool(auto_preview_val) if auto_preview_val is not None else True
self.remember_path = bool(remember_path_val) if remember_path_val is not None else True
self.completion_notify = bool(completion_notify_val) if completion_notify_val is not None else True
+ self.task_mode = bool(task_mode_val) if task_mode_val is not None else False
# Load remembered paths if setting is enabled
if self.remember_path:
@@ -201,8 +449,10 @@ def load_settings(self):
self.icns_method_combo.setCurrentText(str(self.icns_method))
if hasattr(self, 'overwrite_confirm_check'):
self.overwrite_confirm_check.setChecked(bool(self.overwrite_confirm))
+ if hasattr(self, 'task_mode_check'):
+ self.task_mode_check.setChecked(bool(self.task_mode))
- print(f"Settings loaded: min_size={self.min_size}, max_size={self.max_size}, output_format={self.output_format}")
+ print(f"Settings loaded: min_size={self.min_size}, max_size={self.max_size}, output_format={self.output_format}, task_mode={self.task_mode}")
def save_settings(self):
"""Save settings to QSettings"""
@@ -227,6 +477,12 @@ def save_settings(self):
settings.setValue("image_converter/remember_path", self.remember_path)
settings.setValue("image_converter/completion_notify", self.completion_notify)
+ # Save task mode setting
+ if hasattr(self, 'task_mode_check'):
+ settings.setValue("task_mode", self.task_mode_check.isChecked())
+ else:
+ settings.setValue("task_mode", self.task_mode)
+
# Save remembered paths if setting is enabled
if self.remember_path:
if hasattr(self, 'last_input_dir'):
@@ -260,6 +516,18 @@ def init_variables(self, reset_all=False):
self.icns_method = "iconutil (Recommended)"
self.overwrite_confirm = True
+ # Batch conversion variables
+ self.batch_files = []
+ self.batch_converting = False
+ self.batch_worker = None
+ self.batch_thread = None
+ self.batch_current_index = 0
+ self.batch_current_file = ""
+ self.batch_success_count = 0
+ self.batch_failed_count = 0
+ self.batch_canceled = False
+ self.batch_results = [] # Store conversion results
+
# Only reset interface behavior settings if explicitly requested
if reset_all or not hasattr(self, 'auto_preview'):
# Interface behavior settings - prioritize reading from launcher settings
@@ -332,6 +600,7 @@ def setup_ui(self):
self.create_widgets()
self.create_success_view()
+ self.create_batch_success_view()
self.create_history_tab()
self.show_main_view()
@@ -355,11 +624,44 @@ def create_widgets(self):
# Main converter tab
self.converter_tab = QWidget()
- self.tab_widget.addTab(self.converter_tab, "Converter")
+ self.tab_widget.addTab(self.converter_tab, "Signle Converter")
# Setup main converter content
self.setup_converter_tab()
+ # Batch converter tab
+ self.batch_converter_tab = QWidget()
+ self.tab_widget.addTab(self.batch_converter_tab, "Batch Converter")
+
+ # Setup batch converter content
+ self.setup_batch_converter_tab()
+
+ # Preview tab - for image preview functionality
+ self.preview_tab = PreviewTab()
+ self.tab_widget.addTab(self.preview_tab, "Preview")
+
+ # Connect drop signals to update preview tab
+ self._connect_preview_signals()
+
+ def _connect_preview_signals(self):
+ """Connect signals to update preview tab"""
+ # Connect drop zone signals to preview tab
+ self.drop_zone.filesDropped.connect(self.preview_tab.show_multiple_previews)
+ self.drop_zone.folderDropped.connect(self.preview_tab.show_multiple_previews)
+
+ # Connect input text change to preview tab
+ self.input_text.textChanged.connect(self.on_input_text_changed)
+
+ def on_input_text_changed(self):
+ """Handle input text change to update preview"""
+ input_path = self.input_text.text().strip()
+ if input_path and os.path.exists(input_path):
+ # Single file change
+ self.preview_tab.show_single_preview(input_path)
+ else:
+ # Clear preview if invalid path
+ self.preview_tab.clear_previews()
+
def setup_converter_tab(self):
"""Setup the main converter tab content"""
converter_layout = QVBoxLayout(self.converter_tab)
@@ -415,19 +717,6 @@ def setup_converter_tab(self):
output_layout.addWidget(output_button)
file_ops_group_layout.addLayout(output_layout)
- # Image Preview (moved from right panel)
- preview_group_box = QGroupBox("Image Preview")
- preview_group_layout = QVBoxLayout(preview_group_box)
- preview_group_layout.setContentsMargins(10, 25, 10, 10)
- left_layout.addWidget(preview_group_box, 1) # Give preview some stretch on the left
-
- self.preview_label = QLabel()
- self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
- self.preview_label.setFixedSize(250, 250) # Reduced fixed size for the preview area
- self.preview_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) # Allow it to expand
- self._set_placeholder_preview()
- preview_group_layout.addWidget(self.preview_label, 1, Qt.AlignmentFlag.AlignCenter)
-
# Add stretch to left layout to push content to top
left_layout.addStretch(1)
@@ -453,15 +742,18 @@ def setup_converter_tab(self):
# Conversion Options with TreeWidget for better organization
options_group_box = QGroupBox("Conversion Options")
options_group_layout = QVBoxLayout(options_group_box)
- options_group_layout.setContentsMargins(10, 25, 10, 10) # Reset margins for options group
- right_side_v_layout.addWidget(options_group_box, 6) # Give more stretch to options
+ options_group_layout.setContentsMargins(10, 10, 10, 10) # Reset margins for options group
+ options_group_box.setMinimumSize(250,500)
+ right_side_v_layout.addWidget(options_group_box, 8) # Give even more stretch to options
# Create scroll area for TreeWidget
- scroll_area = QScrollArea()
+ scroll_area = ScrollArea()
scroll_area.setWidgetResizable(True)
+ #scroll_area.setMinimumSize(90,90)
scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
- scroll_area.setMinimumHeight(450) # Set minimum height for scroll area
+ # 使用更灵活的高度设置,允许根据内容自动调整
+ scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
# Create TreeWidget for organized settings
self.options_tree = TreeWidget()
@@ -482,6 +774,12 @@ def setup_converter_tab(self):
progress_group_layout.setContentsMargins(10, 25, 10, 10)
left_layout.addWidget(progress_group_box, 0) # No stretch for progress, compact
+ # Task mode control
+ self.task_mode_check = CheckBox("Enable Task Mode")
+ self.task_mode_check.setChecked(False)
+ progress_group_layout.addWidget(self.task_mode_check)
+ progress_group_layout.addSpacing(10)
+
self.progress_label = QLabel("Ready")
self.progress_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.progress = ProgressBar()
@@ -490,7 +788,11 @@ def setup_converter_tab(self):
progress_group_layout.addWidget(self.progress_label)
progress_group_layout.addWidget(self.progress)
- # Convert Button (moved before Progress) - Replace with PrimaryPushButton and apply custom style
+ # Convert and Cancel Button Layout
+ convert_control_layout = QHBoxLayout()
+ convert_control_layout.setSpacing(10)
+
+ # Convert Button - Replace with PrimaryPushButton and apply custom style
self.convert_button = PrimaryPushButton("Convert to ICNS")
self.convert_button.setFixedSize(180, 40)
font = self.convert_button.font()
@@ -500,7 +802,26 @@ def setup_converter_tab(self):
# Apply custom style to convert button
setCustomStyleSheet(self.convert_button, CON.qss, CON.qss)
self.convert_button.clicked.connect(self.on_start_conversion)
- left_layout.addWidget(self.convert_button, 0, Qt.AlignmentFlag.AlignCenter)
+
+ # Cancel Button
+ self.cancel_button = PushButton("Cancel")
+ self.cancel_button.setFixedSize(180, 40)
+ font = self.cancel_button.font()
+ font.setPointSize(font.pointSize() + 1)
+ self.cancel_button.setFont(font)
+ self.cancel_button.setEnabled(False)
+ # Apply custom style to cancel button
+ setCustomStyleSheet(self.cancel_button, CON.qss, CON.qss)
+ self.cancel_button.clicked.connect(self.on_cancel_conversion)
+
+ convert_control_layout.addWidget(self.convert_button)
+ convert_control_layout.addWidget(self.cancel_button)
+ # Create a wrapper widget to center the button layout
+ button_wrapper = QWidget()
+ button_wrapper_layout = QVBoxLayout(button_wrapper)
+ button_wrapper_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ button_wrapper_layout.addLayout(convert_control_layout)
+ left_layout.addWidget(button_wrapper)
@@ -513,6 +834,575 @@ def setup_converter_tab(self):
# Add a stretch to the converter layout to push everything to the top
converter_layout.addStretch(1)
+ def setup_batch_converter_tab(self):
+ """Setup the batch converter tab content"""
+ batch_layout = QVBoxLayout(self.batch_converter_tab)
+
+ # Title for batch converter
+ batch_title_label = QLabel("Batch Image Converter")
+ batch_title_font = QFont()
+ batch_title_font.setPointSize(batch_title_label.font().pointSize() + 6)
+ batch_title_font.setBold(True)
+ batch_title_label.setFont(batch_title_font)
+ batch_title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ batch_layout.addWidget(batch_title_label, 0, Qt.AlignmentFlag.AlignHCenter)
+
+ # Create horizontal layout for main content
+ main_batch_h_layout = QHBoxLayout()
+ main_batch_h_layout.setSpacing(20)
+ batch_layout.addLayout(main_batch_h_layout)
+
+ # Left Panel: File Selection and Preview
+ left_batch_panel = QWidget()
+ left_batch_layout = QVBoxLayout(left_batch_panel)
+ left_batch_layout.setContentsMargins(15, 15, 15, 15)
+ left_batch_layout.setSpacing(15)
+ main_batch_h_layout.addWidget(left_batch_panel, 1)
+
+ # Drop zone for files and folders
+ drop_group_box = QGroupBox("File Selection")
+ drop_group_layout = QVBoxLayout(drop_group_box)
+ drop_group_layout.setContentsMargins(10, 25, 10, 10)
+ drop_group_layout.setSpacing(15)
+ left_batch_layout.addWidget(drop_group_box)
+
+ # Drop zone
+ self.drop_zone = DropZoneWidget()
+ self.drop_zone.setFixedHeight(160)
+ drop_group_layout.addWidget(self.drop_zone)
+
+ # Browse button for manual file selection
+ browse_file_layout = QHBoxLayout()
+ self.browse_file_button = PushButton("Browse...")
+ self.browse_file_button.setText("Browse...")
+ setCustomStyleSheet(self.browse_file_button, CON.qss, CON.qss)
+ self.browse_file_button.clicked.connect(self.on_browse_batch_input)
+
+ browse_file_layout.addStretch()
+ browse_file_layout.addWidget(self.browse_file_button)
+ drop_group_layout.addLayout(browse_file_layout)
+
+ # Connect drop signals
+ self.drop_zone.filesDropped.connect(self.on_batch_files_dropped)
+ self.drop_zone.folderDropped.connect(self.on_batch_folder_dropped)
+
+ # Output Directory Selection (moved here)
+ output_dir_group_box = QGroupBox("Output Directory")
+ output_dir_group_layout = QVBoxLayout(output_dir_group_box)
+ output_dir_group_layout.setContentsMargins(10, 25, 10, 10)
+ output_dir_group_layout.setSpacing(10)
+ left_batch_layout.addWidget(output_dir_group_box, 0)
+
+ # Batch output directory layout with drag-drop support
+ batch_output_layout = QHBoxLayout()
+ batch_output_label = QLabel("📁 Output Directory:")
+ self.batch_output_text = DirectoryDropLineEdit() # 使用新的拖拽支持输入框
+ self.batch_output_text.setPlaceholderText("Same as input files directory")
+ setCustomStyleSheet(self.batch_output_text, CON.qss_line, CON.qss_line)
+
+ batch_output_button = PushButton("Browse...")
+ setCustomStyleSheet(batch_output_button, CON.qss, CON.qss)
+ batch_output_button.clicked.connect(self.on_browse_batch_output)
+
+ batch_output_layout.addWidget(batch_output_label)
+ batch_output_layout.addWidget(self.batch_output_text, 1)
+ batch_output_layout.addWidget(batch_output_button)
+ output_dir_group_layout.addLayout(batch_output_layout)
+
+ # File list with remove functionality
+ file_list_group_box = QGroupBox("Selected Files")
+ file_list_group_layout = QVBoxLayout(file_list_group_box)
+ file_list_group_layout.setContentsMargins(10, 25, 10, 10)
+ left_batch_layout.addWidget(file_list_group_box, 1)
+
+ # File list with scroll area
+ file_scroll_area = ScrollArea()
+ file_scroll_area.setWidgetResizable(True)
+ file_scroll_area.setMinimumHeight(200)
+ file_scroll_area.setMaximumHeight(300)
+
+ self.file_list_widget = ListWidget()
+ file_scroll_area.setWidget(self.file_list_widget)
+ file_list_group_layout.addWidget(file_scroll_area)
+
+ # File management buttons
+ file_mgmt_layout = QHBoxLayout()
+ self.clear_files_btn = PushButton("Clear All")
+ self.remove_selected_btn = PushButton("Remove Selected")
+ setCustomStyleSheet(self.clear_files_btn, CON.qss, CON.qss)
+ setCustomStyleSheet(self.remove_selected_btn, CON.qss, CON.qss)
+
+ self.clear_files_btn.clicked.connect(self.clear_batch_files)
+ self.remove_selected_btn.clicked.connect(self.remove_selected_files)
+
+ file_mgmt_layout.addWidget(self.clear_files_btn)
+ file_mgmt_layout.addWidget(self.remove_selected_btn)
+ file_list_group_layout.addLayout(file_mgmt_layout)
+
+ # Preview area removed - now in dedicated Preview tab
+
+ # Right Panel: Options and Progress
+ right_batch_panel = QWidget()
+ right_batch_layout = QVBoxLayout(right_batch_panel)
+ right_batch_layout.setContentsMargins(15, 15, 15, 15)
+ right_batch_layout.setSpacing(15)
+ main_batch_h_layout.addWidget(right_batch_panel, 1)
+
+ # Batch options (reusing existing options tree)
+ options_group_box = QGroupBox("Conversion Options")
+ options_group_layout = QVBoxLayout(options_group_box)
+ options_group_layout.setContentsMargins(10, 25, 10, 10)
+ right_batch_layout.addWidget(options_group_box, 10)
+
+ # Create scroll area for options
+ batch_scroll_area = ScrollArea()
+ batch_scroll_area.setWidgetResizable(True)
+ # 使用更灵活的尺寸策略
+ batch_scroll_area.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
+
+ self.batch_options_tree = TreeWidget()
+ self.batch_options_tree.setHeaderHidden(True)
+ self.batch_options_tree.setRootIsDecorated(True)
+ self.batch_options_tree.setIndentation(20)
+
+ batch_scroll_area.setWidget(self.batch_options_tree)
+ options_group_layout.addWidget(batch_scroll_area)
+
+ # Setup batch options tree (reuse existing option structure)
+ self._setup_batch_options_tree()
+
+ # Batch Progress & Results section
+ progress_results_group_box = QGroupBox("Batch Progress & Results")
+ progress_results_layout = QVBoxLayout(progress_results_group_box)
+ progress_results_layout.setContentsMargins(10, 25, 10, 10)
+ progress_results_layout.setSpacing(15)
+ right_batch_layout.addWidget(progress_results_group_box, 2)
+
+ # Overall progress
+ self.batch_progress_label = QLabel("Ready")
+ self.batch_progress_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.batch_progress = ProgressBar()
+ self.batch_progress.setRange(0, 100)
+ self.batch_progress.setValue(0)
+
+ progress_results_layout.addWidget(self.batch_progress_label)
+ progress_results_layout.addWidget(self.batch_progress)
+
+ # Current file progress
+ self.current_file_label = QLabel("No file processing")
+ self.current_file_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.current_file_progress = ProgressBar()
+ self.current_file_progress.setRange(0, 100)
+ self.current_file_progress.setValue(0)
+
+ progress_results_layout.addWidget(self.current_file_label)
+ progress_results_layout.addWidget(self.current_file_progress)
+
+ # Separator line
+ separator = QFrame()
+ separator.setFrameShape(QFrame.HLine)
+ separator.setFrameShadow(QFrame.Sunken)
+ progress_results_layout.addWidget(separator)
+
+ # Results display with statistics
+ results_layout = QVBoxLayout()
+ results_layout.setSpacing(10)
+
+ # Statistics
+ stats_layout = QHBoxLayout()
+ self.success_count_label = QLabel("Success: 0")
+ self.failed_count_label = QLabel("Failed: 0")
+ self.total_count_label = QLabel("Total: 0")
+ stats_layout.addWidget(self.success_count_label)
+ stats_layout.addWidget(self.failed_count_label)
+ stats_layout.addWidget(self.total_count_label)
+ stats_layout.addStretch(1)
+ results_layout.addLayout(stats_layout)
+
+ # Results list with scroll
+ results_scroll_area = ScrollArea()
+ results_scroll_area.setWidgetResizable(True)
+ results_scroll_area.setMinimumHeight(150)
+
+ self.results_list_widget = ListWidget()
+ results_scroll_area.setWidget(self.results_list_widget)
+ results_layout.addWidget(results_scroll_area)
+
+ progress_results_layout.addLayout(results_layout)
+
+ # Batch control buttons
+ batch_control_layout = QHBoxLayout()
+ # Initialize with default format text
+ batch_output_format = self.batch_format_combo.currentText().lower() if hasattr(self, 'batch_format_combo') else 'icns'
+ self.start_batch_btn = PrimaryPushButton(f"Convert to {batch_output_format.upper()}")
+ self.stop_batch_btn = PushButton("Stop")
+ self.stop_batch_btn.setEnabled(False)
+
+ setCustomStyleSheet(self.start_batch_btn, CON.qss, CON.qss)
+ setCustomStyleSheet(self.stop_batch_btn, CON.qss, CON.qss)
+
+ self.start_batch_btn.clicked.connect(self.start_batch_conversion)
+ self.stop_batch_btn.clicked.connect(self.stop_batch_conversion)
+
+ batch_control_layout.addWidget(self.start_batch_btn)
+ batch_control_layout.addWidget(self.stop_batch_btn)
+ right_batch_layout.addLayout(batch_control_layout)
+
+ # Add stretch to push content to top
+ right_batch_layout.addStretch(1)
+
+ # Add stretch to push content to top-left
+ batch_layout.addStretch(1)
+
+ # Initialize batch variables
+ self.batch_files = []
+ self.batch_converting = False
+
+ def _setup_batch_options_tree(self):
+ """Setup the TreeWidget with organized settings for batch conversion (same structure as single conversion)"""
+ # Clear existing items
+ self.batch_options_tree.clear()
+
+ # Create main categories with icons for better visual hierarchy (same as single conversion)
+ basic_item = QTreeWidgetItem(["📋 Basic Options"])
+ processing_item = QTreeWidgetItem(["🎨 Image Processing"])
+ output_item = QTreeWidgetItem(["📤 Output Options"])
+ advanced_item = QTreeWidgetItem(["⚙️ Advanced Settings"])
+
+ # Add to tree
+ self.batch_options_tree.addTopLevelItem(basic_item)
+ self.batch_options_tree.addTopLevelItem(processing_item)
+ self.batch_options_tree.addTopLevelItem(output_item)
+ self.batch_options_tree.addTopLevelItem(advanced_item)
+
+ # Set font for categories to make them stand out (same as single conversion)
+ bold_font = self.batch_options_tree.font()
+ bold_font.setBold(True)
+ bold_font.setPointSize(bold_font.pointSize() + 1)
+
+ basic_item.setFont(0, bold_font)
+ processing_item.setFont(0, bold_font)
+ output_item.setFont(0, bold_font)
+ advanced_item.setFont(0, bold_font)
+
+ # Set background colors for categories (same as single conversion)
+ basic_item.setBackground(0, QColor(240, 248, 255, 80)) # Light blue
+ processing_item.setBackground(0, QColor(240, 255, 240, 80)) # Light green
+ output_item.setBackground(0, QColor(255, 250, 205, 80)) # Light yellow
+ advanced_item.setBackground(0, QColor(255, 248, 240, 80)) # Light orange
+
+ # Basic Options (same structure as single conversion)
+ self._create_batch_basic_options(basic_item)
+
+ # Processing Options (same structure as single conversion)
+ self._create_batch_processing_options(processing_item)
+
+ # Output Options (new)
+ self._create_batch_output_options(output_item)
+
+ # Advanced Options (same structure as single conversion)
+ self._create_batch_advanced_options(advanced_item)
+
+ # Set expansion state (same as single conversion)
+ basic_item.setExpanded(True)
+ processing_item.setExpanded(True)
+ output_item.setExpanded(True)
+ advanced_item.setExpanded(False) # Hidden by default
+
+ # Connect tree signals (same as single conversion)
+ self.batch_options_tree.itemExpanded.connect(self._on_batch_tree_item_expanded)
+ self.batch_options_tree.itemCollapsed.connect(self._on_batch_tree_item_collapsed)
+
+ def _create_batch_basic_options(self, parent_item):
+ """Create basic options widgets for batch conversion with responsive layout"""
+ # Output Format
+ format_widget = QWidget()
+ # 使用更灵活的尺寸策略
+ format_widget.setMinimumHeight(55)
+ format_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
+ format_layout = QHBoxLayout(format_widget)
+ format_layout.setContentsMargins(5, 8, 5, 8)
+
+ format_label = QLabel("🗂️ Output Format:")
+ format_label.setMinimumWidth(120) # Ensure consistent width for labels
+
+ self.batch_format_combo = ModelComboBox()
+ self.batch_format_combo.addItems(convert.SUPPORTED_FORMATS)
+ self.batch_format_combo.currentIndexChanged.connect(self.on_batch_format_change)
+ setCustomStyleSheet(self.batch_format_combo, CON.qss_combo, CON.qss_combo)
+
+ format_layout.addWidget(format_label)
+ format_layout.addWidget(self.batch_format_combo, 1) # Give combo box more stretch
+
+ format_item = QTreeWidgetItem()
+ parent_item.addChild(format_item)
+ self.batch_options_tree.setItemWidget(format_item, 0, format_widget)
+
+ # Size Options (grouped in a sub-item)
+ size_item = QTreeWidgetItem(["📏 Size Options"])
+ parent_item.addChild(size_item)
+
+ # Minimum Size
+ min_size_widget = QWidget()
+ min_size_layout = QHBoxLayout(min_size_widget)
+ min_size_layout.setContentsMargins(25, 5, 5, 5) # Indent for sub-item
+ # 使用更灵活的尺寸策略
+ min_size_widget.setMinimumHeight(45)
+ min_size_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
+
+ min_size_label = QLabel("Min Size:")
+ min_size_label.setMinimumWidth(80)
+ self.batch_min_spin = SpinBox()
+ setCustomStyleSheet(self.batch_min_spin, CON.qss_spin, CON.qss_spin)
+ self.batch_min_spin.setRange(16, 512)
+ self.batch_min_spin.setValue(self.min_size)
+ self.batch_min_spin.setSuffix(" px") # Add unit suffix
+ self.batch_min_spin.valueChanged.connect(self.on_batch_min_size_change)
+
+ min_size_layout.addWidget(min_size_label)
+ min_size_layout.addWidget(self.batch_min_spin, 1)
+
+ min_size_sub_item = QTreeWidgetItem()
+ size_item.addChild(min_size_sub_item)
+ self.batch_options_tree.setItemWidget(min_size_sub_item, 0, min_size_widget)
+
+ # Maximum Size
+ max_size_widget = QWidget()
+ max_size_layout = QHBoxLayout(max_size_widget)
+ max_size_layout.setContentsMargins(25, 5, 5, 5) # Indent for sub-item
+ # 使用更灵活的尺寸策略
+ max_size_widget.setMinimumHeight(45)
+ max_size_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
+
+ max_size_label = QLabel("Max Size:")
+ max_size_label.setMinimumWidth(80)
+ self.batch_max_spin = SpinBox()
+ setCustomStyleSheet(self.batch_max_spin, CON.qss_spin, CON.qss_spin)
+ self.batch_max_spin.setRange(32, 1024)
+ self.batch_max_spin.setValue(self.max_size)
+ self.batch_max_spin.setSuffix(" px") # Add unit suffix
+ self.batch_max_spin.valueChanged.connect(self.on_batch_max_size_change)
+
+ max_size_layout.addWidget(max_size_label)
+ max_size_layout.addWidget(self.batch_max_spin, 1)
+
+ max_size_sub_item = QTreeWidgetItem()
+ size_item.addChild(max_size_sub_item)
+ self.batch_options_tree.setItemWidget(max_size_sub_item, 0, max_size_widget)
+
+ # Auto-detect max size checkbox
+ auto_detect_widget = QWidget()
+ auto_detect_layout = QHBoxLayout(auto_detect_widget)
+ auto_detect_layout.setContentsMargins(25, 5, 5, 5) # Indent for sub-item
+ # 使用更灵活的尺寸策略
+ auto_detect_widget.setMinimumHeight(45)
+ auto_detect_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
+
+ self.batch_auto_detect_check = CheckBox("🔍 Auto-detect max size for each image")
+ self.batch_auto_detect_check.stateChanged.connect(self.on_batch_auto_detect_toggle)
+ auto_detect_layout.addWidget(self.batch_auto_detect_check)
+
+ auto_detect_sub_item = QTreeWidgetItem()
+ size_item.addChild(auto_detect_sub_item)
+ self.batch_options_tree.setItemWidget(auto_detect_sub_item, 0, auto_detect_widget)
+
+ # Auto-detect button (disabled for now, will be removed later)
+ # auto_widget = QWidget()
+ # auto_layout = QHBoxLayout(auto_widget)
+ # auto_layout.setContentsMargins(25, 5, 5, 5) # Indent for sub-item
+ # # 使用更灵活的尺寸策略
+ # auto_widget.setMinimumHeight(40)
+ # auto_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
+ #
+ # self.batch_auto_button = PrimaryPushButton("🔍 Auto-detect Max Size")
+ # setCustomStyleSheet(self.batch_auto_button, CON.qss_debug, CON.qss_debug)
+ # self.batch_auto_button.clicked.connect(self.on_batch_auto_detect)
+ # auto_layout.addWidget(self.batch_auto_button)
+ #
+ # auto_sub_item = QTreeWidgetItem()
+ # size_item.addChild(auto_sub_item)
+ # self.batch_options_tree.setItemWidget(auto_sub_item, 0, auto_widget)
+
+ # Expand size options by default
+ size_item.setExpanded(True)
+
+ def _create_batch_processing_options(self, parent_item):
+ """Create image processing options widgets for batch conversion (same as single conversion)"""
+ # Keep aspect ratio
+ aspect_widget = QWidget()
+ aspect_layout = QHBoxLayout(aspect_widget)
+ aspect_layout.setContentsMargins(5, 8, 5, 8)
+ # 使用更灵活的尺寸策略
+ aspect_widget.setMinimumHeight(45)
+ aspect_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
+ self.batch_keep_aspect_check = CheckBox("📐 Maintain original aspect ratio")
+ self.batch_keep_aspect_check.setChecked(self.keep_aspect_ratio)
+ self.batch_keep_aspect_check.stateChanged.connect(self.on_batch_keep_aspect_changed)
+ aspect_layout.addWidget(self.batch_keep_aspect_check)
+
+ aspect_item = QTreeWidgetItem()
+ parent_item.addChild(aspect_item)
+ self.batch_options_tree.setItemWidget(aspect_item, 0, aspect_widget)
+
+ # Auto crop
+ crop_widget = QWidget()
+ crop_layout = QHBoxLayout(crop_widget)
+ crop_layout.setContentsMargins(5, 8, 5, 8)
+ # 使用更灵活的尺寸策略
+ crop_widget.setMinimumHeight(45)
+ crop_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
+ self.batch_auto_crop_check = CheckBox("✂️ Auto-crop non-square to square")
+ self.batch_auto_crop_check.setChecked(self.auto_crop)
+ self.batch_auto_crop_check.stateChanged.connect(self.on_batch_auto_crop_changed)
+ crop_layout.addWidget(self.batch_auto_crop_check)
+ crop_item = QTreeWidgetItem()
+ parent_item.addChild(crop_item)
+ self.batch_options_tree.setItemWidget(crop_item, 0, crop_widget)
+
+ # Quality slider
+ quality_widget = QWidget()
+ quality_layout = QHBoxLayout(quality_widget)
+ quality_layout.setContentsMargins(5, 8, 5, 8)
+ # 使用更灵活的尺寸策略
+ quality_widget.setMinimumHeight(55)
+ quality_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
+ quality_label = QLabel("🎨 Quality:")
+ quality_label.setMinimumWidth(100) # Ensure consistent width for labels
+ quality_layout.addWidget(quality_label)
+
+ self.batch_quality_slider = Slider(Qt.Orientation.Horizontal)
+ self.batch_quality_slider.setRange(1, 100)
+ self.batch_quality_slider.setValue(self.quality)
+ self.batch_quality_slider.valueChanged.connect(self.on_batch_quality_changed)
+ quality_layout.addWidget(self.batch_quality_slider, 1) # Give slider more stretch
+
+ # Use read-only QLabel for quality value, same as single conversion
+ self.batch_quality_label = QLabel(str(self.quality))
+ self.batch_quality_label.setMinimumWidth(30) # Ensure consistent width for label
+ self.batch_quality_label.setMaximumWidth(60) # Limit width
+ self.batch_quality_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ quality_layout.addWidget(self.batch_quality_label)
+
+ quality_item = QTreeWidgetItem()
+ parent_item.addChild(quality_item)
+ self.batch_options_tree.setItemWidget(quality_item, 0, quality_widget)
+
+ def _create_batch_output_options(self, parent_item):
+ """Create batch output options widgets"""
+ # Output Structure options
+ output_structure_item = QTreeWidgetItem(["📁 Output Structure Options"])
+ parent_item.addChild(output_structure_item)
+
+ # Preserve folder structure
+ preserve_folder_widget = QWidget()
+ preserve_folder_layout = QHBoxLayout(preserve_folder_widget)
+ preserve_folder_layout.setContentsMargins(25, 5, 5, 5) # Indent for sub-item
+ # 使用更灵活的尺寸策略
+ preserve_folder_widget.setMinimumHeight(45)
+ preserve_folder_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
+
+ self.batch_preserve_folder_check = CheckBox("Preserve original folder structure")
+ self.batch_preserve_folder_check.setChecked(False)
+ preserve_folder_layout.addWidget(self.batch_preserve_folder_check)
+
+ preserve_folder_sub_item = QTreeWidgetItem()
+ output_structure_item.addChild(preserve_folder_sub_item)
+ self.batch_options_tree.setItemWidget(preserve_folder_sub_item, 0, preserve_folder_widget)
+
+ # Filename modification options
+ filename_mod_item = QTreeWidgetItem(["📝 Filename Modification"])
+ parent_item.addChild(filename_mod_item)
+
+ # Prefix
+ prefix_widget = QWidget()
+ prefix_layout = QHBoxLayout(prefix_widget)
+ prefix_layout.setContentsMargins(25, 5, 5, 5) # Indent for sub-item
+ # 使用更灵活的尺寸策略
+ prefix_widget.setMinimumHeight(45)
+ prefix_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
+
+ prefix_label = QLabel("Prefix:")
+ prefix_label.setMinimumWidth(80)
+ self.batch_prefix_edit = LineEdit()
+ self.batch_prefix_edit.setPlaceholderText("Add prefix to filenames")
+ prefix_layout.addWidget(prefix_label)
+ prefix_layout.addWidget(self.batch_prefix_edit, 1)
+
+ prefix_sub_item = QTreeWidgetItem()
+ filename_mod_item.addChild(prefix_sub_item)
+ self.batch_options_tree.setItemWidget(prefix_sub_item, 0, prefix_widget)
+
+ # Suffix
+ suffix_widget = QWidget()
+ suffix_layout = QHBoxLayout(suffix_widget)
+ suffix_layout.setContentsMargins(25, 5, 5, 5) # Indent for sub-item
+ # 使用更灵活的尺寸策略
+ suffix_widget.setMinimumHeight(45)
+ suffix_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
+
+ suffix_label = QLabel("Suffix:")
+ suffix_label.setMinimumWidth(80)
+ self.batch_suffix_edit = LineEdit()
+ self.batch_suffix_edit.setPlaceholderText("Add suffix to filenames")
+ suffix_layout.addWidget(suffix_label)
+ suffix_layout.addWidget(self.batch_suffix_edit, 1)
+
+ suffix_sub_item = QTreeWidgetItem()
+ filename_mod_item.addChild(suffix_sub_item)
+ self.batch_options_tree.setItemWidget(suffix_sub_item, 0, suffix_widget)
+
+ # Expand output structure options by default
+ output_structure_item.setExpanded(True)
+ filename_mod_item.setExpanded(True)
+
+ def _create_batch_advanced_options(self, parent_item):
+ """Create advanced options widgets for batch conversion (same as single conversion)"""
+ # ICNS method
+ method_widget = QWidget()
+ method_layout = QHBoxLayout(method_widget)
+ method_layout.setContentsMargins(5, 8, 5, 8)
+ # 使用更灵活的尺寸策略
+ method_widget.setMinimumHeight(55)
+ method_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
+
+ method_label = QLabel("⚙️ ICNS method:")
+ method_label.setMinimumWidth(120) # Ensure consistent width for labels
+ method_layout.addWidget(method_label)
+
+ self.batch_icns_method_combo = ModelComboBox()
+ self.batch_icns_method_combo.addItems(["iconutil (Recommended)", "Pillow Fallback"])
+ self.batch_icns_method_combo.setCurrentText(self.icns_method)
+ self.batch_icns_method_combo.currentTextChanged.connect(self.on_batch_icns_method_changed)
+ setCustomStyleSheet(self.batch_icns_method_combo, CON.qss_combo, CON.qss_combo)
+ method_layout.addWidget(self.batch_icns_method_combo, 1) # Give combo box more stretch
+
+ method_item = QTreeWidgetItem()
+ parent_item.addChild(method_item)
+ self.batch_options_tree.setItemWidget(method_item, 0, method_widget)
+
+ # Overwrite confirmation
+ overwrite_widget = QWidget()
+ overwrite_layout = QHBoxLayout(overwrite_widget)
+ overwrite_layout.setContentsMargins(5, 8, 5, 8)
+ # 使用更灵活的尺寸策略
+ overwrite_widget.setMinimumHeight(45)
+ overwrite_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
+ self.batch_overwrite_confirm_check = CheckBox("⚠️ Confirm before overwriting files")
+ self.batch_overwrite_confirm_check.setChecked(self.overwrite_confirm)
+ self.batch_overwrite_confirm_check.stateChanged.connect(self.on_batch_overwrite_confirm_changed)
+ overwrite_layout.addWidget(self.batch_overwrite_confirm_check)
+
+ overwrite_item = QTreeWidgetItem()
+ parent_item.addChild(overwrite_item)
+ self.batch_options_tree.setItemWidget(overwrite_item, 0, overwrite_widget)
+
+ def _update_batch_basic_options(self):
+ """Update batch basic options display"""
+ # Find and update the basic options item
+ basic_item = self.batch_options_tree.topLevelItem(0)
+ if basic_item and basic_item.text(0) == "Basic Options":
+ basic_item.setText(0, f"Basic Options (Min: {self.batch_min_spin.value()}px, Max: {self.batch_max_spin.value()}px, Format: {self.batch_format_combo.currentText()})")
+
def create_history_tab(self):
"""Create the history tab for conversion history"""
if not self.remember_path:
@@ -724,10 +1614,12 @@ def _setup_options_tree(self):
self.options_tree.itemCollapsed.connect(self._on_tree_item_collapsed)
def _create_basic_options(self, parent_item):
- """Create basic options widgets"""
+ """Create basic options widgets with responsive layout"""
# Output Format
format_widget = QWidget()
- format_widget.setMinimumSize(300, 55)
+ # 使用更灵活的尺寸策略而不是固定最小尺寸
+ format_widget.setMinimumHeight(55)
+ format_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
format_layout = QHBoxLayout(format_widget)
format_layout.setContentsMargins(5, 8, 5, 8)
@@ -811,12 +1703,14 @@ def _create_basic_options(self, parent_item):
size_item.setExpanded(True)
def _create_processing_options(self, parent_item):
- """Create image processing options widgets"""
+ """Create image processing options widgets with responsive layout"""
# Keep aspect ratio
aspect_widget = QWidget()
aspect_layout = QHBoxLayout(aspect_widget)
aspect_layout.setContentsMargins(5, 8, 5, 8)
- aspect_widget.setMinimumSize(300, 45)
+ # 使用更灵活的尺寸策略
+ aspect_widget.setMinimumHeight(45)
+ aspect_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
self.keep_aspect_check = CheckBox("📐 Maintain original aspect ratio")
self.keep_aspect_check.stateChanged.connect(self.on_keep_aspect_changed)
aspect_layout.addWidget(self.keep_aspect_check)
@@ -861,12 +1755,14 @@ def _create_processing_options(self, parent_item):
self.options_tree.setItemWidget(quality_item, 0, quality_widget)
def _create_advanced_options(self, parent_item):
- """Create advanced options widgets"""
- # ICNS method
+ """Create advanced settings widgets with responsive layout"""
+ # ICNS method selection
method_widget = QWidget()
method_layout = QHBoxLayout(method_widget)
method_layout.setContentsMargins(5, 8, 5, 8)
- method_widget.setMinimumSize(300, 55)
+ # 使用更灵活的尺寸策略
+ method_widget.setMinimumHeight(55)
+ method_widget.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
method_label = QLabel("⚙️ ICNS method:")
method_label.setMinimumWidth(120) # Ensure consistent width for labels
@@ -896,69 +1792,9 @@ def _create_advanced_options(self, parent_item):
self.options_tree.setItemWidget(overwrite_item, 0, overwrite_widget)
def on_tab_changed(self, index):
- """Handle tab change with optional slide animation effect based on UI_FLUENT environment variable"""
- import sys
- import os
- sys.path.append(os.path.join(os.path.dirname(__file__), 'support'))
- from support.check_flag import check_flag
-
- # Check if UI_FLUENT environment variable is set to YES using check_flag function
- ui_fluent_enabled = check_flag("UI_FLUENT")
-
- # Skip animation if UI_FLUENT is not enabled
- if not ui_fluent_enabled:
- self._previous_tab_index = index
- return
-
- # Proceed with animation if UI_FLUENT is enabled
- from PySide6.QtCore import QPropertyAnimation, QEasingCurve, QRect
-
- # Get current tab widget
- current_widget = self.tab_widget.currentWidget()
- if not current_widget:
- return
-
- # Skip animation during initial startup to prevent layout issues
- if not hasattr(self, '_previous_tab_index') and not self.tab_widget.isVisible():
- self._previous_tab_index = index
- return
-
- # Get tab widget dimensions
- tab_width = self.tab_widget.width()
- tab_height = self.tab_widget.height()
-
- # Skip animation if window is not yet properly sized
- if tab_width <= 0 or tab_height <= 0:
- self._previous_tab_index = index
- return
-
- # Determine slide direction based on tab index
- if hasattr(self, '_previous_tab_index'):
- if index > self._previous_tab_index:
- # Sliding from right to left - start from 80% of width to prevent going out of bounds
- start_pos = QRect(int(tab_width * 0.8), 0, tab_width, tab_height)
- else:
- # Sliding from left to right - start from -80% of width to prevent going out of bounds
- start_pos = QRect(int(-tab_width * 0.8), 0, tab_width, tab_height)
- else:
- # First time, slide from right - start from 80% of width
- start_pos = QRect(int(tab_width * 0.8), 0, tab_width, tab_height)
-
- # Set initial position
- current_widget.setGeometry(start_pos)
-
- # Create slide animation
- self.slide_animation = QPropertyAnimation(current_widget, b"geometry")
- self.slide_animation.setDuration(300) # 300ms animation for smooth slide
- self.slide_animation.setStartValue(start_pos)
- self.slide_animation.setEndValue(QRect(0, 0, tab_width, tab_height))
- self.slide_animation.setEasingCurve(QEasingCurve.Type.OutCubic)
-
- # Store current tab index for next animation
+ """Handle tab change without animation"""
+ # Simply store the previous tab index and return
self._previous_tab_index = index
-
- # Start the animation
- self.slide_animation.start()
def _on_tree_item_expanded(self, item):
"""Handle tree item expansion"""
@@ -1048,18 +1884,177 @@ def create_success_view(self):
self.success_widget.hide() # Initially hidden
+ def create_batch_success_view(self):
+ """Create a specialized batch conversion success view"""
+ # Create batch success widget as a top-level overlay
+ self.batch_success_widget = QWidget(self)
+ self.batch_success_widget.setObjectName("success_overlay")
+
+ # Set it to cover the entire window
+ self.batch_success_widget.setGeometry(self.rect())
+
+ # Create layout for batch success widget
+ self.batch_success_layout = QVBoxLayout(self.batch_success_widget)
+ self.batch_success_layout.setContentsMargins(0, 0, 0, 0)
+
+ # Create a semi-transparent overlay background
+ overlay = QWidget()
+ overlay.setObjectName("success_overlay")
+ overlay_layout = QVBoxLayout(overlay)
+ overlay_layout.setContentsMargins(0, 0, 0, 0)
+
+ center_panel = QWidget()
+ center_panel.setObjectName("success_center_panel")
+ center_layout = QVBoxLayout(center_panel)
+ center_layout.setContentsMargins(50, 50, 50, 50) # Increased margins
+ center_layout.setSpacing(20)
+
+ # Add stretch to center the panel vertically
+ overlay_layout.addStretch()
+ overlay_layout.addWidget(center_panel, 0, Qt.AlignmentFlag.AlignCenter)
+ overlay_layout.addStretch()
+
+ self.batch_success_layout.addWidget(overlay)
+
+ center_layout.addStretch()
+
+ # Batch success title
+ title = QLabel("Batch Conversion Complete!")
+ font = title.font()
+ font.setPointSize(font.pointSize() + 8) # Larger title
+ font.setBold(True)
+ title.setFont(font)
+ title.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ title.setObjectName("success_title_label")
+ center_layout.addWidget(title)
+
+ # Batch checkmark with check symbol
+ checkmark = QLabel("✓")
+ checkmark_font = checkmark.font()
+ checkmark_font.setPointSize(checkmark_font.pointSize() + 30) # Larger checkmark
+ checkmark_font.setBold(True)
+ checkmark.setFont(checkmark_font)
+ checkmark.setObjectName("success_checkmark_label")
+ checkmark.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ center_layout.addWidget(checkmark)
+
+ # Dynamic message - will be updated with actual batch stats
+ self.batch_success_message = QLabel("Processing batch conversion results...")
+ self.batch_success_message.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.batch_success_message.setObjectName("success_message_label")
+ center_layout.addWidget(self.batch_success_message)
+
+ # Batch statistics display
+ stats_widget = QWidget()
+ stats_layout = QVBoxLayout(stats_widget)
+ stats_layout.setSpacing(10)
+
+ self.batch_file_count_label = QLabel("Files Processed: 0")
+ self.batch_file_count_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.batch_file_count_label.setObjectName("batch_file_count_label")
+ stats_layout.addWidget(self.batch_file_count_label)
+
+ self.batch_success_count_label = QLabel("Successfully Converted: 0")
+ self.batch_success_count_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.batch_success_count_label.setObjectName("batch_success_count_label")
+ stats_layout.addWidget(self.batch_success_count_label)
+
+ self.batch_failed_count_label = QLabel("Failed Conversions: 0")
+ self.batch_failed_count_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.batch_failed_count_label.setObjectName("batch_failed_count_label")
+ stats_layout.addWidget(self.batch_failed_count_label)
+
+ self.batch_output_dir_label = QLabel("Output Directory: ")
+ self.batch_output_dir_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.batch_output_dir_label.setWordWrap(True)
+ self.batch_output_dir_label.setObjectName("batch_output_dir_label")
+ stats_layout.addWidget(self.batch_output_dir_label)
+
+ center_layout.addWidget(stats_widget)
+
+ # Button layout
+ button_layout = QHBoxLayout()
+
+ # Open output folder button
+ open_folder_btn = PrimaryPushButton("Open Output Folder")
+ open_folder_btn.clicked.connect(self.on_open_batch_output_folder)
+ open_folder_btn.setFixedSize(200, 45) # Larger buttons
+ open_folder_btn.setObjectName("open_converted_file_button") # Object name for QSS
+ setCustomStyleSheet(open_folder_btn, CON.qss, CON.qss)
+ button_layout.addWidget(open_folder_btn, 0, Qt.AlignmentFlag.AlignCenter)
+
+ # Return to converter button
+ return_btn = PushButton("Return to Converter")
+ return_btn.clicked.connect(self.show_main_view)
+ return_btn.setFixedSize(200, 45) # Larger buttons
+ return_btn.setObjectName("return_to_converter_button") # Object name for QSS
+ setCustomStyleSheet(return_btn, CON.qss, CON.qss)
+ button_layout.addWidget(return_btn, 0, Qt.AlignmentFlag.AlignCenter)
+
+ center_layout.addLayout(button_layout)
+ center_layout.addStretch()
+
+ self.batch_success_widget.hide() # Initially hidden
+
+ def show_batch_success_view(self, total_files=0, success_count=0, failed_count=0, output_dir="", format_name="PNG"):
+ """Show the batch success view with dynamic statistics"""
+ # Update the batch success widget geometry to match the window
+ self.batch_success_widget.setGeometry(self.rect())
+
+ # Update dynamic content
+ if hasattr(self, 'batch_success_message'):
+ if failed_count == 0:
+ self.batch_success_message.setText(f"All {total_files} {format_name.upper()} files converted successfully!")
+ elif success_count == 0:
+ self.batch_success_message.setText(f"Batch conversion failed: {failed_count} files could not be converted")
+ else:
+ self.batch_success_message.setText(f"Batch conversion completed: {success_count}/{total_files} files converted successfully")
+
+ if hasattr(self, 'batch_file_count_label'):
+ self.batch_file_count_label.setText(f"Files Processed: {total_files}")
+
+ if hasattr(self, 'batch_success_count_label'):
+ self.batch_success_count_label.setText(f"Successfully Converted: {success_count}")
+
+ if hasattr(self, 'batch_failed_count_label'):
+ if failed_count > 0:
+ self.batch_failed_count_label.setText(f"Failed Conversions: {failed_count}")
+ self.batch_failed_count_label.show()
+ else:
+ # Hide failed count when it's 0
+ self.batch_failed_count_label.hide()
+
+ if hasattr(self, 'batch_output_dir_label'):
+ self.batch_output_dir_label.setText(f"Output Directory: {output_dir}")
+
+ # Show the batch success widget as an overlay
+ self.batch_success_widget.show()
+ self.batch_success_widget.raise_() # Bring to front
+
+ # Apply theme-specific styles
+ self._apply_success_theme()
+
+ def on_open_batch_output_folder(self):
+ """Open the batch output folder in the file explorer"""
+ output_dir = self.output_dir_input.text().strip()
+ if output_dir and os.path.exists(output_dir):
+ try:
+ if sys.platform == "win32":
+ os.startfile(output_dir)
+ elif sys.platform == "darwin":
+ subprocess.run(["open", output_dir])
+ else: # Linux and other POSIX systems
+ subprocess.run(["xdg-open", output_dir])
+ except Exception as e:
+ QMessageBox.warning(self, "Error", f"Could not open folder: {str(e)}")
+ else:
+ QMessageBox.warning(self, "Error", "Output directory not found")
+
def _set_placeholder_preview(self):
- placeholder_text = "Drag and drop image here\nor click 'Browse...' to select file\n🖼️"
- font = QFont()
- font.setPointSize(16) # Larger font for placeholder
- self.preview_label.setFont(font)
- self.preview_label.setText(placeholder_text)
- self.preview_label.setPixmap(QPixmap()) # Clear any previous image
-
- # Enable drag and drop for the preview label
- self.preview_label.setAcceptDrops(True)
- self.preview_label.dragEnterEvent = self.dragEnterEvent
- self.preview_label.dropEvent = self.dropEvent
+ """Set placeholder text in the preview tab"""
+ # Clear existing previews and show default info
+ self.preview_tab.clear_previews()
+
def on_browse_input(self):
file_dialog = QFileDialog(self)
@@ -1131,6 +2126,41 @@ def on_browse_output(self):
# Remember the directory for next time if setting is enabled
if self.remember_path:
self.last_output_dir = os.path.dirname(self.output_path)
+
+ def on_browse_batch_input(self):
+ """Browse for batch input files"""
+ file_dialog = QFileDialog(self)
+ file_dialog.setFileMode(QFileDialog.FileMode.ExistingFiles)
+ file_dialog.setNameFilter("Supported Images (*.png *.jpg *.jpeg *.gif *.bmp *.webp *.ico *.tiff);;All Files (*)")
+ file_dialog.setViewMode(QFileDialog.ViewMode.Detail)
+
+ # Use last directory if available
+ if hasattr(self, 'last_batch_input_dir') and self.last_batch_input_dir:
+ file_dialog.setDirectory(self.last_batch_input_dir)
+
+ if file_dialog.exec() == QFileDialog.DialogCode.Accept:
+ selected_files = file_dialog.selectedFiles()
+ if selected_files:
+ # Remember this directory for future use
+ self.last_batch_input_dir = os.path.dirname(selected_files[0])
+
+ # Add files to batch file list
+ for file_path in selected_files:
+ self._add_file_to_batch_list(file_path)
+
+ def on_browse_batch_output(self):
+ """Browse for batch output directory"""
+ directory = QFileDialog.getExistingDirectory(
+ self,
+ "Select Output Directory for Batch Conversion",
+ self.batch_output_text.text() if self.batch_output_text.text() else os.path.expanduser("~"),
+ QFileDialog.Option.ShowDirsOnly
+ )
+
+ if directory:
+ self.batch_output_text.setText(directory)
+ # Remember this directory for future use
+ self.last_batch_output_dir = directory
def on_format_change(self, index):
self.output_format = self.format_combo.currentText().lower()
@@ -1147,6 +2177,28 @@ def on_format_change(self, index):
self.max_spin.setEnabled(enable_size_options)
self.auto_button.setEnabled(enable_size_options)
+ def on_batch_format_change(self, index):
+ """Handle format change in batch conversion"""
+ batch_output_format = self.batch_format_combo.currentText().lower()
+
+ # Update batch conversion button text to match format
+ if hasattr(self, 'start_batch_btn'):
+ self.start_batch_btn.setText(f"Convert to {batch_output_format.upper()}")
+
+ # Enable/disable auto-detect button based on format
+ if hasattr(self, 'batch_use_auto_detect'):
+ if batch_output_format in ['png', 'jpg', 'jpeg', 'webp']:
+ self.batch_use_auto_detect.setEnabled(True)
+ else:
+ self.batch_use_auto_detect.setEnabled(False)
+ self.batch_use_auto_detect.setChecked(False)
+
+ # Enable/disable min/max spin boxes based on format
+ if hasattr(self, 'batch_min_spin') and hasattr(self, 'batch_max_spin'):
+ enable_size_options = (batch_output_format == "icns")
+ self.batch_min_spin.setEnabled(enable_size_options)
+ self.batch_max_spin.setEnabled(enable_size_options)
+
def auto_set_output(self):
if self.input_path:
base_name = os.path.splitext(os.path.basename(self.input_path))[0]
@@ -1199,30 +2251,17 @@ def on_auto_detect(self):
def show_preview(self):
+ """Show preview in the preview tab"""
if self.input_path and os.path.exists(self.input_path):
try:
+ # Use the preview_tab to display the single preview
+ self.preview_tab.show_single_preview(self.input_path)
img = Image.open(self.input_path)
- # img.thumbnail((350, 350)) # Removed PIL thumbnailing
-
- if img.mode == 'RGBA':
- qimage = QImage(img.tobytes("raw", "RGBA"), img.size[0], img.size[1], QImage.Format.Format_RGBA8888) # Corrected enum
- else:
- qimage = QImage(img.tobytes("raw", "RGB"), img.size[0], img.size[1], QImage.Format.Format_RGB888) # Corrected enum
-
- pixmap = QPixmap.fromImage(qimage)
- # Scale pixmap to fit the label, maintaining aspect ratio
- scaled_pixmap = pixmap.scaled(self.preview_label.size(), Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
- self.preview_label.setPixmap(scaled_pixmap)
- # Reset font to default if it was changed by placeholder
- self.preview_label.setFont(QFont())
- self.preview_label.setText("") # Clear placeholder text
self.status_bar.showMessage(f"Loaded: {os.path.basename(self.input_path)} ({img.size[0]}x{img.size[1]})")
except Exception as e:
- self.preview_label.setText("Preview error")
self.status_bar.showMessage("Preview error")
else:
- self.preview_label.clear()
- self._set_placeholder_preview() # Show placeholder when no image selected
+ self.preview_tab.clear_previews()
self.status_bar.showMessage("Ready")
def on_min_size_change(self, value):
@@ -1247,6 +2286,32 @@ def on_quality_changed(self, value):
self.quality_label.setText(str(value))
self.save_settings()
+ def on_batch_auto_detect_toggle(self, state):
+ """Handle auto-detect toggle in batch conversion"""
+ auto_detect_enabled = bool(state)
+ # Enable/disable min and max size spin boxes based on auto-detect setting
+ if hasattr(self, 'batch_min_spin') and hasattr(self, 'batch_max_spin'):
+ self.batch_min_spin.setEnabled(not auto_detect_enabled)
+ self.batch_max_spin.setEnabled(not auto_detect_enabled)
+
+ # Enable/disable auto-detect button if it exists
+ if hasattr(self, 'batch_auto_button'):
+ self.batch_auto_button.setEnabled(not auto_detect_enabled)
+
+ def on_batch_auto_detect(self):
+ """Handle auto-detect in batch conversion"""
+ # This method is deprecated, will be removed in future
+ PopupTeachingTip.create(
+ target=self.batch_auto_button,
+ icon=InfoBarIcon.WARNING,
+ title='Notice',
+ content="Auto-detect is now available through the checkbox option.",
+ isClosable=True,
+ tailPosition=TeachingTipTailPosition.TOP,
+ duration=3000,
+ parent=self
+ )
+
def on_icns_method_changed(self, text):
self.icns_method = text
self.save_settings()
@@ -1254,6 +2319,45 @@ def on_icns_method_changed(self, text):
def on_overwrite_confirm_changed(self, state):
self.overwrite_confirm = bool(state)
self.save_settings()
+
+ # Batch conversion event handlers (mirroring single conversion handlers)
+ def on_batch_min_size_change(self, value):
+ if hasattr(self, 'batch_min_size'):
+ self.batch_min_size = value
+
+ def on_batch_max_size_change(self, value):
+ if hasattr(self, 'batch_max_size'):
+ self.batch_max_size = value
+
+ def on_batch_quality_changed(self, value):
+ if hasattr(self, 'batch_quality'):
+ self.batch_quality = value
+ if hasattr(self, 'batch_quality_label'):
+ self.batch_quality_label.setText(str(value))
+
+ def on_batch_icns_method_changed(self, text):
+ if hasattr(self, 'batch_icns_method'):
+ self.batch_icns_method = text
+
+ def on_batch_overwrite_confirm_changed(self, state):
+ if hasattr(self, 'batch_overwrite_confirm'):
+ self.batch_overwrite_confirm = bool(state)
+
+ def on_batch_keep_aspect_changed(self, state):
+ if hasattr(self, 'batch_keep_aspect_ratio'):
+ self.batch_keep_aspect_ratio = bool(state)
+
+ def on_batch_auto_crop_changed(self, state):
+ if hasattr(self, 'batch_auto_crop'):
+ self.batch_auto_crop = bool(state)
+
+ def _on_batch_tree_item_expanded(self, item):
+ """Handle batch tree item expansion"""
+ pass # Could be enhanced for batch-specific behavior
+
+ def _on_batch_tree_item_collapsed(self, item):
+ """Handle batch tree item collapse"""
+ pass # Could be enhanced for batch-specific behavior
def on_interface_setting_changed(self):
"""Handle changes to interface behavior settings"""
@@ -1371,8 +2475,40 @@ def on_start_conversion(self):
)
return
+ # Check if task mode is enabled
+ settings = QSettings("MyCompany", "ConverterApp")
+ task_mode_enabled = settings.value("task_mode", False, type=bool)
+
+ if task_mode_enabled:
+ # Create a simple task notification file
+ import json
+ import uuid
+ import time
+
+ task_id = str(uuid.uuid4())
+ task_info = {
+ "task_id": task_id,
+ "task_type": "image",
+ "input_path": self.input_path,
+ "output_path": self.output_path,
+ "status": "pending",
+ "progress": 0,
+ "timestamp": time.time()
+ }
+
+ # Write task info to a temporary file
+ task_dir = os.path.expanduser("~/.converter/tasks")
+ os.makedirs(task_dir, exist_ok=True)
+ task_file = os.path.join(task_dir, f"task_{task_id}.json")
+ with open(task_file, "w") as f:
+ json.dump(task_info, f)
+
+ # Store task_id for later updates
+ self.current_task_id = task_id
+
self.converting = True
self.convert_button.setEnabled(False)
+ self.cancel_button.setEnabled(True)
self.progress.setValue(0)
self.progress_label.setText("Starting conversion...")
@@ -1391,12 +2527,37 @@ def on_start_conversion(self):
self._thread.started.connect(self._worker.run)
self._thread.start()
+ def on_cancel_conversion(self):
+ """Cancel the ongoing conversion process"""
+ if not self.converting:
+ return
+
+ # Update UI to show cancellation
+ self.progress_label.setText("Canceling conversion...")
+ self.cancel_button.setEnabled(False)
+
+ # Stop the worker thread safely
+ if hasattr(self, '_thread') and self._thread.isRunning():
+ self._thread.quit()
+ self._thread.wait()
+
+ # Update UI state
+ self.converting = False
+ self.convert_button.setEnabled(True)
+ self.progress.setValue(0)
+ self.progress_label.setText("Conversion canceled")
+ self.status_bar.showMessage("Conversion canceled by user")
+
def on_conversion_finished(self):
self.converting = False
if hasattr(self, '_thread') and self._thread.isRunning():
self._thread.quit()
self._thread.wait()
+ # Reset button states
+ self.convert_button.setEnabled(True)
+ self.cancel_button.setEnabled(False)
+
# Add to history if remember_path is enabled
if self.remember_path:
self.add_to_history(self.input_path, self.output_path, str(self.output_format))
@@ -1421,17 +2582,63 @@ def on_conversion_finished(self):
self.show_success_view()
else:
# If completion notification is disabled, just show a simple status message
- self.convert_button.setEnabled(True)
self.progress.setValue(100)
self.progress_label.setText("Conversion completed successfully!")
self.status_bar.showMessage(f"Conversion completed: {os.path.basename(self.output_path)}")
+ # Auto-reset after conversion completion
+ self._reset_after_conversion()
+
+ def _reset_after_conversion(self):
+ """Reset conversion state after completion to prepare for next conversion"""
+ # Only reset variables if remember_path is disabled
+ if not self.remember_path:
+ # Reset all variables when remember_path is disabled
+ self.init_variables(reset_all=True)
+
+ # Reset UI elements
+ if hasattr(self, 'input_text'):
+ self.input_text.clear()
+ if hasattr(self, 'output_text'):
+ self.output_text.clear()
+ if hasattr(self, 'info_text'):
+ self.info_text.setText("No image selected")
+ if hasattr(self, 'preview_tab'):
+ self.preview_tab.clear_previews() # Clear previews when returning to main view
+
def on_conversion_error(self, error_message):
self.converting = False
self.convert_button.setEnabled(True)
+ self.cancel_button.setEnabled(False) # Reset cancel button state
if hasattr(self, '_thread') and self._thread.isRunning():
self._thread.quit()
self._thread.wait()
+
+ # Update task status if task mode is enabled
+ if hasattr(self, 'current_task_id') and self.current_task_id:
+ try:
+ import json
+ import time
+
+ task_dir = os.path.expanduser("~/.converter/tasks")
+ task_file = os.path.join(task_dir, f"task_{self.current_task_id}.json")
+
+ # Read existing task info
+ if os.path.exists(task_file):
+ with open(task_file, "r") as f:
+ task_info = json.load(f)
+
+ # Update task info
+ task_info["status"] = "failed"
+ task_info["error"] = error_message
+ task_info["timestamp"] = time.time()
+
+ # Write updated task info
+ with open(task_file, "w") as f:
+ json.dump(task_info, f)
+ except Exception as e:
+ print(f"Error updating task status: {e}")
+
PopupTeachingTip.create(
target=self.convert_button,
icon=InfoBarIcon.ERROR,
@@ -1444,6 +2651,370 @@ def on_conversion_error(self, error_message):
)
self.progress_label.setText("Conversion Failed")
+ # Batch conversion callback methods
+ def on_batch_files_dropped(self, file_paths):
+ """Handle multiple files dropped in the batch converter"""
+ # Filter only supported image files
+ supported_formats = ('.png', '.jpg', '.jpeg', '.webp', '.ico', '.tiff', '.tif', '.icns', '.bmp', '.gif', '.svg', '.heic', '.heif', '.avif', '.jxl', '.pdf', '.eps', '.dds', '.exr')
+ new_files = []
+
+ for file_path in file_paths:
+ if os.path.splitext(file_path.lower())[1] in supported_formats:
+ new_files.append(file_path)
+
+ if new_files:
+ self.batch_files.extend(new_files)
+ self.update_batch_file_list()
+ # Update preview tab with multiple previews
+ self.preview_tab.show_multiple_previews(new_files)
+ self.update_batch_statistics()
+
+ def on_batch_folder_dropped(self, folder_path):
+ """Handle folder dropped in the batch converter"""
+ # Scan folder for supported image files
+ supported_formats = ('.png', '.jpg', '.jpeg', '.webp', '.ico', '.tiff', '.tif', '.icns', '.bmp', '.gif', '.svg', '.heic', '.heif', '.avif', '.jxl', '.pdf', '.eps', '.dds', '.exr')
+ found_files = []
+
+ try:
+ for root, dirs, files in os.walk(folder_path):
+ for file in files:
+ if any(file.lower().endswith(ext) for ext in supported_formats):
+ file_path = os.path.join(root, file)
+ found_files.append(file_path)
+ except Exception as e:
+ PopupTeachingTip.create(
+ target=self.drop_zone,
+ icon=InfoBarIcon.ERROR,
+ title='ERROR',
+ content=f"Cannot scan folder: {str(e)}",
+ isClosable=True,
+ tailPosition=TeachingTipTailPosition.TOP,
+ duration=3000,
+ parent=self
+ )
+ return
+
+ if found_files:
+ self.batch_files.extend(found_files)
+ self.update_batch_file_list()
+ # Update preview tab with multiple previews from folder
+ self.preview_tab.show_multiple_previews(found_files)
+ self.update_batch_statistics()
+
+ PopupTeachingTip.create(
+ target=self.drop_zone,
+ icon=InfoBarIcon.SUCCESS,
+ title='SUCCESS',
+ content=f"Added {len(found_files)} files from folder: {os.path.basename(folder_path)}",
+ isClosable=True,
+ tailPosition=TeachingTipTailPosition.TOP,
+ duration=3000,
+ parent=self
+ )
+ else:
+ PopupTeachingTip.create(
+ target=self.drop_zone,
+ icon=InfoBarIcon.WARNING,
+ title='WARNING',
+ content="No supported image files found in the folder",
+ isClosable=True,
+ tailPosition=TeachingTipTailPosition.TOP,
+ duration=3000,
+ parent=self
+ )
+
+ def update_batch_file_list(self):
+ """Update the file list widget with current batch files"""
+ self.file_list_widget.clear()
+ for file_path in self.batch_files:
+ item = QListWidgetItem(os.path.basename(file_path))
+ item.setToolTip(file_path)
+ self.file_list_widget.addItem(item)
+
+ def update_batch_statistics(self):
+ """Update batch conversion statistics"""
+ total_count = len(self.batch_files)
+ self.total_count_label.setText(f"Total: {total_count}")
+
+ def clear_batch_files(self):
+ """Clear all batch files"""
+ self.batch_files.clear()
+ self.update_batch_file_list()
+ # Clear preview tab
+ self.preview_tab.clear_previews()
+ self.update_batch_statistics()
+ self.clear_batch_results()
+
+ def remove_selected_files(self):
+ """Remove selected files from batch"""
+ selected_items = self.file_list_widget.selectedItems()
+ if not selected_items:
+ return
+
+ # Get indices of selected items
+ indices = []
+ for item in selected_items:
+ indices.append(self.file_list_widget.row(item))
+
+ # Sort indices in descending order to remove from end
+ indices.sort(reverse=True)
+
+ # Remove files from batch_files list
+ for index in indices:
+ if 0 <= index < len(self.batch_files):
+ del self.batch_files[index]
+
+ self.update_batch_file_list()
+ # Update preview tab with remaining files
+ self.preview_tab.show_multiple_previews(self.batch_files)
+ self.update_batch_statistics()
+
+ def start_batch_conversion(self):
+ """Start batch conversion process"""
+ if not self.batch_files:
+ PopupTeachingTip.create(
+ target=self.start_batch_btn,
+ icon=InfoBarIcon.WARNING,
+ title='WARNING',
+ content="Please select files to convert",
+ isClosable=True,
+ tailPosition=TeachingTipTailPosition.TOP,
+ duration=2000,
+ parent=self
+ )
+ return
+
+ # Get current conversion options
+ options = self._get_batch_conversion_options()
+ if not options:
+ return
+
+ # Start batch conversion in background thread
+ self.batch_converting = True
+ self.start_batch_btn.setEnabled(False)
+ self.stop_batch_btn.setEnabled(True)
+
+ # Reset progress and results
+ self.batch_progress.setValue(0)
+ self.current_file_progress.setValue(0)
+ self.clear_batch_results()
+
+ # Start batch conversion thread
+ self.batch_thread = QThread()
+ self.batch_worker = BatchConversionWorker(
+ self.batch_files,
+ self.get_batch_output_directory(),
+ options['output_format'],
+ min_size_param=options['min_size'],
+ max_size_param=options['max_size'],
+ quality_param=options['quality'],
+ preserve_folder_structure=options['preserve_folder_structure'],
+ prefix=options['prefix'],
+ suffix=options['suffix'],
+ auto_detect_max_size=options['auto_detect_max_size']
+ )
+ self.batch_worker.moveToThread(self.batch_thread)
+
+ # Connect signals
+ self.batch_worker.total_progress_updated.connect(self.on_batch_progress)
+ self.batch_worker.progress_updated.connect(self.on_batch_file_progress)
+ self.batch_worker.file_processed.connect(self.on_batch_file_processed)
+ self.batch_worker.finished.connect(self.on_batch_finished)
+ self.batch_worker.batch_error.connect(self.on_batch_error)
+
+ # Start the thread
+ self.batch_thread.started.connect(self.batch_worker.run)
+ self.batch_thread.start()
+
+ def stop_batch_conversion(self):
+ """Stop batch conversion process"""
+ if hasattr(self, 'batch_worker') and self.batch_converting:
+ self.batch_worker.cancel()
+ self.on_batch_stopped()
+
+ def on_batch_stopped(self):
+ """Handle batch conversion stopped"""
+ self.batch_converting = False
+ self.start_batch_btn.setEnabled(True)
+ self.stop_batch_btn.setEnabled(False)
+ self.batch_progress_label.setText("Stopped")
+ self.current_file_label.setText("No file processing")
+
+ # Clean up thread
+ if hasattr(self, 'batch_thread') and self.batch_thread.isRunning():
+ self.batch_thread.quit()
+ self.batch_thread.wait()
+
+ def on_batch_progress(self, overall_progress, message=None):
+ """Update batch overall progress"""
+ self.batch_progress.setValue(overall_progress)
+ if message:
+ self.batch_progress_label.setText(message)
+ else:
+ self.batch_progress_label.setText(f"Overall progress: {overall_progress}%")
+
+ def on_batch_file_progress(self, current_index, total_count, current_file, percentage):
+ """Update current file progress"""
+ self.current_file_progress.setValue(percentage)
+ self.current_file_label.setText(f"Processing {current_file} ({current_index}/{total_count})")
+
+ def on_batch_file_processed(self, filename, input_path, output_path, success, error_message):
+ """Handle individual file processing result"""
+ if success:
+ item_text = f"✓ {filename}"
+ item = QListWidgetItem(item_text)
+ item.setForeground(Qt.green)
+ # Add to history if conversion was successful and remember_path is enabled
+ if self.remember_path:
+ format_type = self.batch_format_combo.currentText().lower()
+ self.add_to_history(input_path, output_path, format_type)
+ else:
+ item_text = f"✗ {filename}: {error_message}"
+ item = QListWidgetItem(item_text)
+ item.setForeground(Qt.red)
+
+ self.results_list_widget.addItem(item)
+
+ def on_batch_finished(self):
+ """Handle batch conversion finished"""
+ self.batch_converting = False
+ self.start_batch_btn.setEnabled(True)
+ self.stop_batch_btn.setEnabled(False)
+
+ # Clean up thread
+ if hasattr(self, 'batch_thread') and self.batch_thread.isRunning():
+ self.batch_thread.quit()
+ self.batch_thread.wait()
+
+ # Get statistics from results list
+ success_count = 0
+ failed_count = 0
+
+ for i in range(self.results_list_widget.count()):
+ item = self.results_list_widget.item(i)
+ if item:
+ if "✓" in item.text():
+ success_count += 1
+ else:
+ failed_count += 1
+
+ self.success_count_label.setText(f"Success: {success_count}")
+ self.failed_count_label.setText(f"Failed: {failed_count}")
+
+ # Update statistics label
+ total_files = success_count + failed_count
+ self.batch_progress_label.setText(f"Completed: {total_files} files")
+
+ # Get output directory path
+ output_dir = self.get_batch_output_directory()
+
+ # Show batch success view (even if there were some failures, we still show the results)
+ self.show_batch_success_view(
+ total_files=total_files,
+ success_count=success_count,
+ failed_count=failed_count,
+ output_dir=output_dir,
+ format_name=self.batch_format_combo.currentText()
+ )
+
+ # Auto-reset after batch conversion completion
+ self._reset_batch_after_conversion()
+
+ def _reset_batch_after_conversion(self):
+ """Reset batch conversion state after completion to prepare for next conversion"""
+ # Clear file list and results
+ self.clear_batch_files()
+ self.clear_batch_results()
+
+ # Reset progress bars
+ self.batch_progress.setValue(0)
+ self.current_file_progress.setValue(0)
+ self.batch_progress_label.setText("Ready for new conversion")
+ self.current_file_label.setText("No file processing")
+
+ # Clear preview tab
+ if hasattr(self, 'preview_tab'):
+ self.preview_tab.clear_previews()
+
+ def on_batch_error(self, error_message):
+ """Handle batch conversion error"""
+ self.batch_converting = False
+ self.start_batch_btn.setEnabled(True)
+ self.stop_batch_btn.setEnabled(False)
+
+ PopupTeachingTip.create(
+ target=self.start_batch_btn,
+ icon=InfoBarIcon.ERROR,
+ title='ERROR',
+ content=f"Batch conversion failed: {error_message}",
+ isClosable=True,
+ tailPosition=TeachingTipTailPosition.TOP,
+ duration=5000,
+ parent=self
+ )
+
+ # Clean up thread
+ if hasattr(self, 'batch_thread') and self.batch_thread.isRunning():
+ self.batch_thread.quit()
+ self.batch_thread.wait()
+
+ def clear_batch_results(self):
+ """Clear batch conversion results"""
+ self.results_list_widget.clear()
+ self.success_count_label.setText("Success: 0")
+ self.failed_count_label.setText("Failed: 0")
+
+ def _get_batch_conversion_options(self):
+ """Get conversion options from batch options tree (same structure as single conversion)"""
+ try:
+ options = {
+ 'output_format': self.batch_format_combo.currentText(),
+ 'min_size': self.batch_min_spin.value(),
+ 'max_size': self.batch_max_spin.value(),
+ 'use_auto_detect': getattr(self, 'batch_use_auto_detect', False),
+ 'auto_detect_max_size': self.batch_auto_detect_check.isChecked() if hasattr(self, 'batch_auto_detect_check') else False,
+ 'keep_aspect_ratio': self.batch_keep_aspect_check.isChecked(),
+ 'auto_crop': self.batch_auto_crop_check.isChecked(),
+ 'quality': self.batch_quality_slider.value(),
+ 'icns_method': self.batch_icns_method_combo.currentText(),
+ 'overwrite_confirm': self.batch_overwrite_confirm_check.isChecked(),
+ 'preserve_folder_structure': self.batch_preserve_folder_check.isChecked() if hasattr(self, 'batch_preserve_folder_check') else False,
+ 'prefix': self.batch_prefix_edit.text().strip() if hasattr(self, 'batch_prefix_edit') else "",
+ 'suffix': self.batch_suffix_edit.text().strip() if hasattr(self, 'batch_suffix_edit') else ""
+ }
+ return options
+ except Exception as e:
+ PopupTeachingTip.create(
+ target=self.batch_options_tree,
+ icon=InfoBarIcon.ERROR,
+ title='ERROR',
+ content=f"Invalid conversion options: {str(e)}",
+ isClosable=True,
+ tailPosition=TeachingTipTailPosition.TOP,
+ duration=3000,
+ parent=self
+ )
+ return None
+
+ def get_output_directory(self):
+ """Get output directory for batch conversion"""
+ if hasattr(self, 'output_text') and self.output_text.text():
+ return self.output_text.text()
+ else:
+ # Default to current directory if no output path set
+ return os.path.dirname(self.input_text.text()) if hasattr(self, 'input_text') and self.input_text.text() else os.getcwd()
+
+ def get_batch_output_directory(self):
+ """Get output directory for batch conversion"""
+ if hasattr(self, 'batch_output_text') and self.batch_output_text.text():
+ return self.batch_output_text.text()
+ elif hasattr(self, 'output_text') and self.output_text.text():
+ # Fall back to single conversion output directory
+ return self.output_text.text()
+ else:
+ # Default to current directory if no output path set
+ return os.getcwd()
+
def show_success_view(self):
# Update the success widget geometry to match the window
self.success_widget.setGeometry(self.rect())
@@ -1496,24 +3067,12 @@ def show_main_view(self):
if hasattr(self, 'success_widget'):
self.success_widget.hide()
- # Only reset variables if remember_path is disabled
- if not self.remember_path:
- # Reset all variables when remember_path is disabled
- self.init_variables(reset_all=True)
-
- # Reset UI elements
- if hasattr(self, 'input_text'):
- self.input_text.clear()
- if hasattr(self, 'output_text'):
- self.output_text.clear()
- if hasattr(self, 'info_text'):
- self.info_text.setText("No image selected")
- if hasattr(self, 'preview_label'):
- self.preview_label.clear()
- self._set_placeholder_preview() # Show placeholder when returning to main view
- else:
- # If remember_path is enabled, only reset the UI state but keep the paths
- pass
+ # Hide the batch success widget if it's shown
+ if hasattr(self, 'batch_success_widget'):
+ self.batch_success_widget.hide()
+
+ # Reset conversion state when returning to main view
+ self._reset_after_conversion()
# Always reset these UI elements regardless of remember_path setting
if hasattr(self, 'min_spin'):
@@ -1548,6 +3107,35 @@ def show_main_view(self):
if hasattr(self, 'status_bar'):
self.status_bar.showMessage("Ready")
+ # Reset batch conversion related UI elements when returning to main view
+ if hasattr(self, 'batch_output_text'):
+ self.batch_output_text.clear() # Clear batch output directory
+ if hasattr(self, 'batch_format_combo'):
+ self.batch_format_combo.setCurrentText("icns") # Reset batch output format
+ if hasattr(self, 'batch_min_spin'):
+ self.batch_min_spin.setValue(16) # Reset batch min size
+ if hasattr(self, 'batch_max_spin'):
+ self.batch_max_spin.setValue(1024) # Reset batch max size
+ if hasattr(self, 'batch_options_tree'):
+ self.batch_options_tree.collapseAll() # Reset batch options tree
+ # Expand basic categories for batch options
+ if self.batch_options_tree.topLevelItemCount() >= 3:
+ item0 = self.batch_options_tree.topLevelItem(0)
+ item1 = self.batch_options_tree.topLevelItem(1)
+ item2 = self.batch_options_tree.topLevelItem(2)
+ if item0:
+ item0.setExpanded(True) # Basic Options
+ if item1:
+ item1.setExpanded(True) # Image Processing
+ if item2:
+ item2.setExpanded(False) # Advanced Settings
+ if hasattr(self, 'start_batch_btn'):
+ self.start_batch_btn.setEnabled(True) # Reset batch start button
+ if hasattr(self, 'stop_batch_btn'):
+ self.stop_batch_btn.setEnabled(False) # Reset batch stop button
+ if hasattr(self, 'results_list_widget'):
+ self.results_list_widget.clear() # Clear batch results list
+
# Only update history tab visibility if it's the first time or remember_path setting changed
if not hasattr(self, '_history_tab_initialized'):
self.create_history_tab()
@@ -1557,6 +3145,32 @@ def show_main_view(self):
def update_progress(self, message, percentage):
self.progress_label.setText(message)
self.progress.setValue(percentage)
+
+ # Update task progress file if task mode is enabled
+ if hasattr(self, 'current_task_id') and self.current_task_id:
+ try:
+ import json
+ import time
+
+ task_dir = os.path.expanduser("~/.converter/tasks")
+ task_file = os.path.join(task_dir, f"task_{self.current_task_id}.json")
+
+ # Read existing task info
+ if os.path.exists(task_file):
+ with open(task_file, "r") as f:
+ task_info = json.load(f)
+
+ # Update task info
+ task_info["status"] = "running"
+ task_info["progress"] = percentage
+ task_info["message"] = message
+ task_info["timestamp"] = time.time()
+
+ # Write updated task info
+ with open(task_file, "w") as f:
+ json.dump(task_info, f)
+ except Exception as e:
+ print(f"Error updating task progress: {e}")
def center_window(self):
qr = self.frameGeometry()
@@ -1610,6 +3224,9 @@ def resizeEvent(self, event):
# Update the success widget geometry when the window is resized
if hasattr(self, 'success_widget') and self.success_widget:
self.success_widget.setGeometry(self.rect())
+ # Update the batch success widget geometry when the window is resized
+ if hasattr(self, 'batch_success_widget') and self.batch_success_widget:
+ self.batch_success_widget.setGeometry(self.rect())
class ICNSConverterApp:
diff --git a/qss/converter_dark.qss b/qss/converter_dark.qss
index f14d2c4..d80a149 100644
--- a/qss/converter_dark.qss
+++ b/qss/converter_dark.qss
@@ -106,42 +106,3 @@ QTabBar::tab:!selected:hover {
background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #0056b3, stop:1 #004085);
}
-/* Enhanced CheckBox */
-QCheckBox {
- color: #e0e0e0;
- font-weight: 500;
-}
-
-QCheckBox::indicator {
- width: 18px;
- height: 18px;
- border-radius: 4px;
- border: 2px solid rgba(255, 255, 255, 0.2);
- background-color: rgba(45, 45, 45, 0.8);
-}
-
-QCheckBox::indicator:checked {
- background-color: #2196F3;
- border-color: #2196F3;
- image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAiIGhlaWdodD0iMTAiIHZpZXdCb3g9IjAgMCAxMCAxMCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTggM0w0IDdMMiA1IiBzdHJva2U9IndoaXRlIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8L3N2Zz4K);
-}
-
-QCheckBox::indicator:hover {
- border-color: #2196F3;
- background-color: rgba(33, 150, 243, 0.1);
-}
-
-/* Success overlay styles */
-#success_overlay {
- background-color: rgba(0, 0, 0, 180);
-}
-
-#success_center_panel {
- background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
- stop:0 rgba(60, 60, 60, 0.9),
- stop:1 rgba(45, 45, 45, 0.7));
- border-radius: 25px;
- border: 1px solid rgba(255, 255, 255, 0.1);
- padding: 35px;
- min-width: 420px;
-}
\ No newline at end of file
diff --git a/qss/settings_dark.qss b/qss/settings_dark.qss
deleted file mode 100644
index 3443e09..0000000
--- a/qss/settings_dark.qss
+++ /dev/null
@@ -1,259 +0,0 @@
-QDialog {
- background-color: #2b2b2b;
- color: #e0e0e0;
- font-family: Arial, sans-serif;
- border-radius: 20px;
-}
-
-QLabel {
- color: #e0e0e0;
- background-color: transparent;
-}
-
-/* Checkbox Styles */
-QCheckBox {
- color: #e0e0e0;
- background-color: transparent;
- spacing: 8px;
-}
-
-QCheckBox::indicator {
- width: 18px;
- height: 18px;
- background-color: transparent;
- border: 2px solid #5a5a5a;
- border-radius: 16px;
-}
-
-QCheckBox::indicator:checked {
- background-color: #0078d4;
- border-color: #0078d4;
- image: url("data:image/svg+xml;utf8,");
-}
-
-QCheckBox::indicator:hover {
- border-color: #0078d4;
- background-color: rgba(0, 120, 212, 0.1);
-}
-
-QCheckBox::indicator:checked:hover {
- background-color: #106ebe;
- border-color: #106ebe;
-}
-
-QCheckBox:focus {
- outline: none;
- background-color: transparent;
-}
-
-QPushButton {
- background-color: #2196F3;
- border: none;
- color: white;
- padding: 12px 20px;
- border-radius: 18px;
- font-weight: bold;
- font-size: 13px;
-}
-PushButton,ToolButton,PrimaryPushButton,PrimaryToolButton{
- border-radius: 16px
-}
-QPushButton:hover {
- background-color: #1976D2;
-}
-
-QPushButton:pressed {
- background-color: #0D47A1;
-}
-
-QComboBox {
- border: none;
- border-radius: 16px;
- padding: 8px 12px;
- background-color: rgba(45, 45, 45, 0.6);
- color: #ffffff;
- selection-background-color: #4a90e2;
- selection-color: #ffffff;
-}
-
-QComboBox:editable {
- background-color: rgba(45, 45, 45, 0.6);
-}
-
-/* Debug Settings Widget Styles */
-QGroupBox {
- border: none;
- border-radius: 20px;
- margin-top: 15px;
- padding-top: 25px;
- font-weight: bold;
- background-color: rgba(51, 51, 51, 0.4);
- color: #e0e0e0;
-}
-
-QGroupBox::title {
- subcontrol-origin: margin;
- left: 15px;
- padding: 0 8px 0 8px;
- color: #e0e0e0;
- background-color: transparent;
-}
-
-QComboBox:!editable, QComboBox::drop-down:editable {
- background-color: rgba(45, 45, 45, 0.6);
-}
-
-QComboBox:!editable:on, QComboBox::drop-down:editable:on {
- background-color: rgba(45, 45, 45, 0.6);
-}
-
-QComboBox:on {
- background-color: rgba(45, 45, 45, 0.6);
-}
-
-QComboBox::drop-down {
- subcontrol-origin: padding;
- subcontrol-position: top right;
- width: 24px;
- border: none;
- border-top-right-radius: 15px;
- border-bottom-right-radius: 15px;
- background-color: rgba(68, 68, 68, 0.7);
-}
-
-QComboBox::down-arrow {
- image: url("data:image/svg+xml;utf8,");
- width: 16px;
- height: 12px;
-}
-
-QComboBox::down-arrow:on {
- image: url("data:image/svg+xml;utf8,");
-}
-
-QComboBox QAbstractItemView {
- border: none;
- border-radius: 16px;
- background-color: rgba(45, 45, 45, 0.8);
- color: #ffffff;
- selection-background-color: #4a90e2;
- selection-color: #ffffff;
- outline: 0px;
-}
-
-QComboBox QAbstractItemView::item {
- padding: 8px 15px;
- border-radius: 16px;
- margin: 2px;
-}
-
-QComboBox QAbstractItemView::item:selected {
- background-color: #4a90e2;
- color: #ffffff;
-}
-
-QComboBox QAbstractItemView::item:hover {
- background-color: rgba(58, 58, 58, 0.8);
-}
-
-#status_label {
- color: #aaaaaa;
- font-size: 12px;
- font-style: italic;
- background-color: transparent;
-}
-
-#settings_apply_button {
- background-color: #4CAF50; /* Green for apply button */
- border-radius: 18px;
-}
-
-#settings_apply_button:hover {
- background-color: #43A047;
-}
-
-#settings_apply_button:pressed {
- background-color: #2E7D32;
-}
-
-/* Update Button Styles */
-QPushButton#settings_button {
- background-color: #2196F3; /* Blue */
- border: none;
- color: white;
- padding: 15px 20px;
- border-radius: 20px;
- font-weight: bold;
- font-size: 14px;
- text-align: center;
-}
-
-QPushButton#settings_button:hover {
- background-color: #1976D2;
-}
-
-QPushButton#settings_button:pressed {
- background-color: #0D47A1;
-}
-
-/* Text Elements with Transparent Backgrounds */
-QWidget QLabel {
- color: #e0e0e0;
- background-color: transparent;
-}
-
-/* TabWidget with Slide Animation */
-QTabWidget::pane {
- border: 1px solid #5a5a5a;
- border-radius: 20px;
- padding: 5px;
- background-color: rgba(45, 45, 45, 0.6);
-}
-
-QTabBar::tab {
- background: #404040;
- border: 1px solid #5a5a5a;
- border-radius: 4px 4px 0px 0px;
- padding: 12px 20px;
- margin-right: 4px;
- color: #b3b3b3;
- font-weight: 500;
-}
-
-QTabBar::tab:selected {
- background: #0078d4;
- color: #ffffff;
- border-bottom: 2px solid #0078d4;
-}
-
-QTabBar::tab:hover {
- background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #4a4a4a, stop:1 #3a3a3a);
- color: #e0e0e0;
-}
-
-QTabBar::tab:!selected:hover {
- background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #454545, stop:1 #353535);
- color: #cccccc;
-}
- background-color: transparent;
-}
-
-SegmentedWidget QLabel {
- background-color: transparent;
- color: #e0e0e0;
-}
-
-StackedWidget QLabel {
- background-color: transparent;
- color: #e0e0e0;
-}
-
-/* Ensure all text has transparent background */
-* {
- selection-background-color: rgba(0, 120, 212, 0.3);
-}
-
-*:focus {
- outline: none;
- background-color: transparent;
-}
\ No newline at end of file
diff --git a/qss/settings_light.qss b/qss/settings_light.qss
deleted file mode 100644
index 8b09c06..0000000
--- a/qss/settings_light.qss
+++ /dev/null
@@ -1,259 +0,0 @@
-QDialog {
- background-color: #f0f0f0;
- color: #333;
- font-family: Arial, sans-serif;
- border-radius: 20px;
-}
-
-QLabel {
- color: #333;
- background-color: transparent;
-}
-
-/* Checkbox Styles */
-QCheckBox {
- color: #333;
- background-color: transparent;
- spacing: 8px;
-}
-
-QCheckBox::indicator {
- width: 18px;
- height: 18px;
- background-color: transparent;
- border: 2px solid #bdc3c7;
- border-radius: 16px;
-}
-
-QCheckBox::indicator:checked {
- background-color: #3498db;
- border-color: #3498db;
- image: url("data:image/svg+xml;utf8,");
-}
-
-QCheckBox::indicator:hover {
- border-color: #3498db;
- background-color: rgba(52, 152, 219, 0.1);
-}
-
-QCheckBox::indicator:checked:hover {
- background-color: #2980b9;
- border-color: #2980b9;
-}
-
-QCheckBox:focus {
- outline: none;
- background-color: transparent;
-}
-
-QPushButton {
- background-color: #2196F3;
- border: none;
- color: white;
- padding: 12px 20px;
- border-radius: 18px;
- font-weight: bold;
- font-size: 13px;
-}
-PushButton,ToolButton,PrimaryPushButton,PrimaryToolButton{
- border-radius: 16px
-}
-QPushButton:hover {
- background-color: #1976D2;
-}
-
-QPushButton:pressed {
- background-color: #0D47A1;
-}
-
-QComboBox {
- border: none;
- border-radius: 16px;
- padding: 8px 12px;
- background-color: rgba(255, 255, 255, 0.7);
- color: #000000;
- selection-background-color: #4a90e2;
- selection-color: #ffffff;
-}
-
-QComboBox:editable {
- background-color: rgba(255, 255, 255, 0.7);
-}
-
-QComboBox:!editable, QComboBox::drop-down:editable {
- background-color: rgba(255, 255, 255, 0.7);
-}
-
-/* Debug Settings Widget Styles */
-QGroupBox {
- border: none;
- border-radius: 20px;
- margin-top: 15px;
- padding-top: 25px;
- font-weight: bold;
- background-color: rgba(248, 249, 250, 0.6);
- color: #333333;
-}
-
-QGroupBox::title {
- subcontrol-origin: margin;
- left: 15px;
- padding: 0 8px 0 8px;
- color: #333333;
- background-color: transparent;
-}
-
-QComboBox:!editable:on, QComboBox::drop-down:editable:on {
- background-color: rgba(255, 255, 255, 0.7);
-}
-
-QComboBox:on {
- background-color: rgba(255, 255, 255, 0.7);
-}
-
-QComboBox::drop-down {
- subcontrol-origin: padding;
- subcontrol-position: top right;
- width: 24px;
- border: none;
- border-top-right-radius: 15px;
- border-bottom-right-radius: 15px;
- background-color: rgba(204, 204, 204, 0.7);
-}
-
-QComboBox::down-arrow {
- image: url("data:image/svg+xml;utf8,");
- width: 16px;
- height: 12px;
-}
-
-QComboBox::down-arrow:on {
- image: url("data:image/svg+xml;utf8,");
-}
-
-QComboBox QAbstractItemView {
- border: none;
- border-radius: 16px;
- background-color: rgba(255, 255, 255, 0.9);
- color: #000000;
- selection-background-color: #4a90e2;
- selection-color: #ffffff;
- outline: 0px;
-}
-
-QComboBox QAbstractItemView::item {
- padding: 8px 15px;
- border-radius: 16px;
- margin: 2px;
-}
-
-QComboBox QAbstractItemView::item:selected {
- background-color: #4a90e2;
- color: #ffffff;
-}
-
-QComboBox QAbstractItemView::item:hover {
- background-color: rgba(0, 0, 0, 0.05);
-}
-
-#status_label {
- color: #555;
- font-size: 12px;
- font-style: italic;
- background-color: transparent;
-}
-
-#settings_apply_button {
- background-color: #4CAF50; /* Green for apply button */
- border-radius: 18px;
-}
-
-#settings_apply_button:hover {
- background-color: #43A047;
-}
-
-#settings_apply_button:pressed {
- background-color: #2E7D32;
-}
-
-/* Update Button Styles */
-QPushButton#settings_button {
- background-color: #2196F3; /* Blue */
- border: none;
- color: white;
- padding: 15px 20px;
- border-radius: 20px;
- font-weight: bold;
- font-size: 14px;
- text-align: center;
-}
-
-QPushButton#settings_button:hover {
- background-color: #1976D2;
-}
-
-QPushButton#settings_button:pressed {
- background-color: #0D47A1;
-}
-
-/* Text Elements with Transparent Backgrounds */
-QWidget QLabel {
- color: #333;
- background-color: transparent;
-}
-
-/* TabWidget with Slide Animation */
-QTabWidget::pane {
- border: 1px solid #bdc3c7;
- border-radius: 20px;
- padding: 5px;
- background-color: rgba(255, 255, 255, 0.7);
-}
-
-QTabBar::tab {
- background: #ecf0f1;
- border: 1px solid #bdc3c7;
- border-radius: 4px 4px 0px 0px;
- padding: 12px 20px;
- margin-right: 4px;
- color: #7f8c8d;
- font-weight: 500;
-}
-
-QTabBar::tab:selected {
- background: #3498db;
- color: #ffffff;
- border-bottom: 2px solid #3498db;
-}
-
-QTabBar::tab:hover {
- background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #d5dbdb, stop:1 #c5cbcb);
- color: #2c3e50;
-}
-
-QTabBar::tab:!selected:hover {
- background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #d0d3d4, stop:1 #c0c3c4);
- color: #34495e;
-}
- background-color: transparent;
-}
-
-SegmentedWidget QLabel {
- background-color: transparent;
- color: #333;
-}
-
-StackedWidget QLabel {
- background-color: transparent;
- color: #333;
-}
-
-/* Ensure all text has transparent background */
-* {
- selection-background-color: rgba(52, 152, 219, 0.3);
-}
-
-*:focus {
- outline: none;
- background-color: transparent;
-}
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
index 5b2b4e0..155cf77 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -5,5 +5,4 @@ rarfile
py7zr
requests
PySide6-Fluent-Widgets[full]
-patool
cryptography
diff --git a/settings/general_settings.py b/settings/general_settings.py
index 5e4a635..aec90b9 100644
--- a/settings/general_settings.py
+++ b/settings/general_settings.py
@@ -2,149 +2,81 @@
General Settings Widget
"""
-from PySide6.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QGroupBox,
- QLabel, QLineEdit)
+from PySide6.QtWidgets import QWidget, QVBoxLayout
from PySide6.QtCore import Qt, Signal, QSettings
-from PySide6.QtGui import QIntValidator
-from qfluentwidgets import *
+from qfluentwidgets import (
+ SettingCardGroup, SwitchSettingCard, PushSettingCard, PrimaryPushSettingCard,
+ FluentIcon, BodyLabel, CaptionLabel, PasswordLineEdit, InfoBar, InfoBarPosition,
+ setCustomStyleSheet, ExpandGroupSettingCard, SingleDirectionScrollArea
+)
import os
import sys
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from utils.security import encrypt_pat, decrypt_pat
-from con import CON
-class GeneralSettingsWidget(QWidget):
- """General settings widget"""
-
- settings_changed = Signal()
+
+
+class GitHubSettingCard(ExpandGroupSettingCard):
+ """GitHub PAT settings card using ExpandGroupSettingCard"""
def __init__(self, parent=None):
- super().__init__(parent)
- self.settings = QSettings("pyquick", "converter")
- self.pat_input = None # Initialize PAT input field reference
- self.setup_ui()
- self.load_settings()
- self.connect_signals()
-
- def setup_ui(self):
- """Setup the UI layout"""
- main_layout = QVBoxLayout(self)
- main_layout.setContentsMargins(15, 15, 15, 15)
- main_layout.setSpacing(15)
+ super().__init__(
+ FluentIcon.GITHUB,
+ "GitHub Settings",
+ "Configure GitHub Personal Access Token for API access",
+ parent
+ )
+ from qfluentwidgets import ComboBox, PushButton
-
-
- # Image Converter settings group
- image_converter_group = QGroupBox("Image Converter Settings")
- image_converter_layout = QVBoxLayout(image_converter_group)
-
- # Interface behavior settings
- behavior_group = QGroupBox("Interface Behavior Settings")
- behavior_layout = QVBoxLayout(behavior_group)
-
- self.auto_preview_check = CheckBox("Auto-show preview after selecting file")
- behavior_layout.addWidget(self.auto_preview_check)
- behavior_layout.addSpacing(10)
- self.remember_path_check = CheckBox("Remember last selected input/output paths")
- behavior_layout.addWidget(self.remember_path_check)
- behavior_layout.addSpacing(10)
- self.completion_notify_check = CheckBox("Show success notification after conversion")
- behavior_layout.addWidget(self.completion_notify_check)
- behavior_layout.addSpacing(10)
-
- image_converter_layout.addWidget(behavior_group)
- image_converter_layout.addStretch()
-
- # GitHub Settings Group
- github_group = QGroupBox("GitHub Settings")
- github_layout = QVBoxLayout()
-
- # GitHub PAT Settings
- pat_layout = QHBoxLayout()
- pat_label = QLabel("GitHub PAT (Personal Access Token):")
self.pat_input = PasswordLineEdit()
self.pat_input.setPlaceholderText("ghp_xxxxxxxxxxxxxxxxxxxx")
self.pat_input.setClearButtonEnabled(True)
- self.pat_input.setFixedHeight(33)
- setCustomStyleSheet(self.pat_input,"PasswordLineEdit{ border-radius: 16px; }","PasswordLineEdit{ border-radius: 16px; }")
- pat_layout.addWidget(pat_label)
- pat_layout.addWidget(self.pat_input)
- github_layout.addLayout(pat_layout)
-
- # PAT Info
- pat_info = QLabel("Used for GitHub API access, recommend only 'repo' permission")
- pat_info.setStyleSheet("color: #666; font-size: 11px;")
- github_layout.addWidget(pat_info)
- self.qss_debug = """PushButton{ border-radius: 12px; }"""
- # PAT Test Button
- test_pat_btn = PushButton("Test PAT")
- setCustomStyleSheet(test_pat_btn,self.qss_debug,self.qss_debug)
- test_pat_btn.clicked.connect(self.test_pat)
- github_layout.addWidget(test_pat_btn)
-
- github_group.setLayout(github_layout)
- main_layout.addWidget(github_group)
- main_layout.addWidget(image_converter_group)
- main_layout.addStretch()
-
- def connect_signals(self):
- """Connect signals for auto-save"""
- # Image converter settings signals
- self.auto_preview_check.stateChanged.connect(self.on_settings_changed)
- self.remember_path_check.stateChanged.connect(self.on_settings_changed)
- self.completion_notify_check.stateChanged.connect(self.on_settings_changed)
-
- # Placeholder for future general settings signals
- pass
-
- def on_settings_changed(self):
- """Emit settings changed signal and save settings"""
- self.save_settings()
- self.settings_changed.emit()
-
- def load_settings(self):
- """Load settings from QSettings"""
- settings = QSettings("MyCompany", "ConverterApp")
+ self.pat_input.setFixedHeight(40)
+ setCustomStyleSheet(self.pat_input, "PasswordLineEdit{ border-radius: 16px; }", "PasswordLineEdit{ border-radius: 16px; }")
- # Image converter settings
- self.auto_preview_check.setChecked(settings.value("image_converter/auto_preview", True, type=bool))
- self.remember_path_check.setChecked(settings.value("image_converter/remember_path", True, type=bool))
- self.completion_notify_check.setChecked(settings.value("image_converter/completion_notify", True, type=bool))
+ self.test_button = PushButton("Test PAT")
+ self.test_button.setFixedWidth(135)
+ self.test_button.setFixedHeight(46)
+ self.test_button.clicked.connect(self.test_pat)
- # GitHub PAT settings
- encrypted_pat = settings.value("general/github_pat", "", type=str)
- if encrypted_pat:
- decrypted_pat = decrypt_pat(encrypted_pat)
- self.pat_input.setText(decrypted_pat)
-
- def save_settings(self):
- """Save settings to QSettings"""
- settings = QSettings("MyCompany", "ConverterApp")
+ self.permission_combo = ComboBox()
+ self.permission_combo.addItems(["repo", "public_repo", "repo:status", "repo_deployment"])
+ self.permission_combo.setFixedWidth(135)
+ self.permission_combo.setFixedHeight(46)
+ self.permission_combo.setCurrentText("repo")
- # Image converter settings
- settings.setValue("image_converter/auto_preview", self.auto_preview_check.isChecked())
- settings.setValue("image_converter/remember_path", self.remember_path_check.isChecked())
- settings.setValue("image_converter/completion_notify", self.completion_notify_check.isChecked())
+ self.viewLayout.setContentsMargins(0, 0, 0, 0)
+ self.viewLayout.setSpacing(0)
- # GitHub PAT settings
- pat_text = self.pat_input.text().strip()
- if pat_text:
- encrypted_pat = encrypt_pat(pat_text)
- settings.setValue("general/github_pat", encrypted_pat)
- else:
- settings.setValue("general/github_pat", "")
-
+ self.addGroup(
+ FluentIcon.SAVE,
+ "Personal Access Token",
+ "Enter your GitHub PAT for API access",
+ self.pat_input
+ )
+ self.addGroup(
+ FluentIcon.CHECKBOX,
+ "Recommended Permission",
+ "Select the permission scope for your PAT",
+ self.permission_combo
+ )
+ self.addGroup(
+ FluentIcon.EDIT,
+ "Test Connection",
+ "Verify your PAT is valid",
+ self.test_button
+ )
+
def test_pat(self):
"""Test GitHub PAT validity"""
pat = self.pat_input.text().strip()
if not pat:
- PopupTeachingTip.create(
- target=self.pat_input,
- icon=InfoBarIcon.WARNING,
+ InfoBar.warning(
title='Warning',
content='Please enter GitHub PAT first',
+ orient=Qt.Orientation.Horizontal,
isClosable=True,
- tailPosition=TeachingTipTailPosition.TOP,
+ position=InfoBarPosition.TOP,
duration=2000,
parent=self
)
@@ -152,7 +84,6 @@ def test_pat(self):
try:
import requests
- # Test PAT API endpoint
response = requests.get(
"https://api.github.com/user",
headers={
@@ -165,61 +96,211 @@ def test_pat(self):
if response.status_code == 200:
user_data = response.json()
username = user_data.get("login", "Unknown")
- PopupTeachingTip.create(
- target=self.pat_input,
- icon=InfoBarIcon.SUCCESS,
+ InfoBar.success(
title='Success',
content=f'PAT verified! Associated user: {username}',
+ orient=Qt.Orientation.Horizontal,
isClosable=True,
- tailPosition=TeachingTipTailPosition.TOP,
+ position=InfoBarPosition.TOP,
duration=3000,
parent=self
)
elif response.status_code == 401:
- PopupTeachingTip.create(
- target=self.pat_input,
- icon=InfoBarIcon.ERROR,
+ InfoBar.error(
title='Error',
content='PAT invalid or expired',
+ orient=Qt.Orientation.Horizontal,
isClosable=True,
- tailPosition=TeachingTipTailPosition.TOP,
+ position=InfoBarPosition.TOP,
duration=3000,
parent=self
)
else:
- PopupTeachingTip.create(
- target=self.pat_input,
- icon=InfoBarIcon.ERROR,
+ InfoBar.error(
title='Error',
content=f'PAT verification failed (status code: {response.status_code})',
+ orient=Qt.Orientation.Horizontal,
isClosable=True,
- tailPosition=TeachingTipTailPosition.TOP,
+ position=InfoBarPosition.TOP,
duration=3000,
parent=self
)
except requests.exceptions.RequestException as e:
- PopupTeachingTip.create(
- target=self.pat_input,
- icon=InfoBarIcon.ERROR,
+ InfoBar.error(
title='Error',
content=f'Network error: {str(e)}',
+ orient=Qt.Orientation.Horizontal,
isClosable=True,
- tailPosition=TeachingTipTailPosition.TOP,
+ position=InfoBarPosition.TOP,
duration=3000,
parent=self
)
except Exception as e:
- PopupTeachingTip.create(
- target=self.pat_input,
- icon=InfoBarIcon.ERROR,
+ InfoBar.error(
title='Error',
content=f'Test failed: {str(e)}',
+ orient=Qt.Orientation.Horizontal,
isClosable=True,
- tailPosition=TeachingTipTailPosition.TOP,
- duration=3000,
- parent=self
- )
+ position=InfoBarPosition.TOP,
+ duration=3000,
+ parent=self
+ )
+
+ def get_pat(self):
+ """Get current PAT text"""
+ return self.pat_input.text().strip()
+
+ def set_pat(self, pat):
+ """Set PAT text"""
+ self.pat_input.setText(pat)
+
+ def get_permission(self):
+ """Get selected permission"""
+ return self.permission_combo.currentText()
+
+ def set_permission(self, permission):
+ """Set permission"""
+ self.permission_combo.setCurrentText(permission)
+
+
+class GeneralSettingsWidget(QWidget):
+ """General settings widget using qfluentwidgets SettingCard components"""
+
+ settings_changed = Signal()
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.settings = QSettings("MyCompany", "ConverterApp")
+ self.setup_ui()
+ self.connect_signals()
+ self.load_settings()
+
+ def setup_ui(self):
+ """Setup the UI layout"""
+ main_layout = QVBoxLayout(self)
+ main_layout.setContentsMargins(0, 0, 0, 0)
+ main_layout.setSpacing(0)
+
+ self.scroll_area = SingleDirectionScrollArea(orient=Qt.Orientation.Vertical)
+ self.scroll_area.setWidgetResizable(True)
+ self.scroll_area.enableTransparentBackground()
+
+ scroll_content = QWidget()
+ scroll_content.setObjectName("scroll_content")
+ scroll_layout = QVBoxLayout(scroll_content)
+ scroll_layout.setContentsMargins(30, 30, 30, 30)
+ scroll_layout.setSpacing(20)
+
+ self.behavior_group = SettingCardGroup("Interface Behavior", scroll_content)
+
+ self.auto_preview_card = SwitchSettingCard(
+ FluentIcon.VIEW,
+ "Auto-show preview",
+ "Automatically show preview after selecting file",
+ parent=self.behavior_group
+ )
+
+ self.remember_path_card = SwitchSettingCard(
+ FluentIcon.HISTORY,
+ "Remember paths",
+ "Remember last selected input/output paths",
+ parent=self.behavior_group
+ )
+
+ self.completion_notify_card = SwitchSettingCard(
+ FluentIcon.COMPLETED,
+ "Completion notification",
+ "Show success notification after conversion",
+ parent=self.behavior_group
+ )
+
+ self.task_mode_card = SwitchSettingCard(
+ FluentIcon.SETTING,
+ "Task Mode",
+ "Enable task queue for batch processing",
+ parent=self.behavior_group
+ )
+
+ self.github_card = GitHubSettingCard(scroll_content)
+
+ self.behavior_group.addSettingCards([
+ self.auto_preview_card,
+ self.remember_path_card,
+ self.completion_notify_card,
+ self.task_mode_card
+ ])
+
+ scroll_layout.addWidget(self.behavior_group)
+ scroll_layout.addWidget(self.github_card)
+ scroll_layout.addStretch()
+
+ self.scroll_area.setWidget(scroll_content)
+
+ main_layout.addWidget(self.scroll_area)
+
+ def connect_signals(self):
+ """Connect signals for auto-save"""
+ self.auto_preview_card.checkedChanged.connect(self.on_settings_changed)
+ self.remember_path_card.checkedChanged.connect(self.on_settings_changed)
+ self.completion_notify_card.checkedChanged.connect(self.on_settings_changed)
+ self.task_mode_card.checkedChanged.connect(self.on_settings_changed)
+
+ # Connect GitHub settings signals
+ self.github_card.pat_input.textChanged.connect(self.on_settings_changed)
+ self.github_card.permission_combo.currentTextChanged.connect(self.on_settings_changed)
+
+ def on_settings_changed(self):
+ """Emit settings changed signal and save settings"""
+ self.save_settings()
+ self.settings_changed.emit()
+
+ def load_settings(self):
+ """Load settings from QSettings"""
+ # Block signals during loading to prevent auto-save
+ self.auto_preview_card.blockSignals(True)
+ self.remember_path_card.blockSignals(True)
+ self.completion_notify_card.blockSignals(True)
+ self.task_mode_card.blockSignals(True)
+ self.github_card.pat_input.blockSignals(True)
+ self.github_card.permission_combo.blockSignals(True)
+
+ self.auto_preview_card.setChecked(bool(self.settings.value("image_converter/auto_preview", True, type=bool)))
+ self.remember_path_card.setChecked(bool(self.settings.value("image_converter/remember_path", True, type=bool)))
+ self.completion_notify_card.setChecked(bool(self.settings.value("image_converter/completion_notify", True, type=bool)))
+ self.task_mode_card.setChecked(bool(self.settings.value("task_mode", False, type=bool)))
+
+ encrypted_pat = self.settings.value("general/github_pat", "", type=str)
+ if encrypted_pat:
+ decrypted_pat = decrypt_pat(str(encrypted_pat))
+ self.github_card.set_pat(decrypted_pat)
+
+ permission = self.settings.value("general/github_permission", "repo", type=str)
+ self.github_card.set_permission(str(permission))
+
+ # Unblock signals after loading
+ self.auto_preview_card.blockSignals(False)
+ self.remember_path_card.blockSignals(False)
+ self.completion_notify_card.blockSignals(False)
+ self.task_mode_card.blockSignals(False)
+ self.github_card.pat_input.blockSignals(False)
+ self.github_card.permission_combo.blockSignals(False)
+
+ def save_settings(self):
+ """Save settings to QSettings"""
+ self.settings.setValue("image_converter/auto_preview", self.auto_preview_card.isChecked())
+ self.settings.setValue("image_converter/remember_path", self.remember_path_card.isChecked())
+ self.settings.setValue("image_converter/completion_notify", self.completion_notify_card.isChecked())
+ self.settings.setValue("task_mode", self.task_mode_card.isChecked())
+
+ pat_text = self.github_card.get_pat()
+ if pat_text:
+ encrypted_pat = encrypt_pat(pat_text)
+ self.settings.setValue("general/github_pat", encrypted_pat)
+ else:
+ self.settings.setValue("general/github_pat", "")
+
+ self.settings.setValue("general/github_permission", self.github_card.get_permission())
if __name__ == "__main__":
@@ -229,4 +310,4 @@ def test_pat(self):
app = QApplication(sys.argv)
widget = GeneralSettingsWidget()
widget.show()
- sys.exit(app.exec())
\ No newline at end of file
+ sys.exit(app.exec())
diff --git a/settings/settings_gui.py b/settings/settings_gui.py
index 27526bc..3d9671d 100644
--- a/settings/settings_gui.py
+++ b/settings/settings_gui.py
@@ -15,7 +15,7 @@
from PySide6.QtGui import QPalette
from PySide6.QtCore import Qt, QSettings, QPropertyAnimation, QEasingCurve, Signal, Slot
from qfluentwidgets import SegmentedWidget, setCustomStyleSheet
-from .update_settings_gui import UpdateDialog
+from .update_settings_gui import UpdateSettingsWidget
from con import CON
class SettingsDialog(QDialog):
@@ -133,7 +133,7 @@ def init_ui(self):
update_layout.setContentsMargins(15, 15, 15, 15)
update_layout.setSpacing(15)
- self.update_group = UpdateDialog()
+ self.update_group = UpdateSettingsWidget()
self.update_group.setObjectName("update_group")
update_layout.addWidget(self.update_group)
update_layout.addStretch()
diff --git a/settings/update_settings_gui.py b/settings/update_settings_gui.py
index fd3519c..654059b 100644
--- a/settings/update_settings_gui.py
+++ b/settings/update_settings_gui.py
@@ -1,35 +1,21 @@
# -*- coding: utf-8 -*-
-#Please do not change import
-from concurrent.futures import thread
-import multiprocessing
+"""
+Update Settings GUI Widget for Converter application
+"""
+
import sys
import os
-import threading
-
-from PySide6.QtWidgets import (
- QApplication,
- QWidget,
- QPushButton,
- QVBoxLayout,
- QHBoxLayout,
- QLabel,
- QCheckBox,
- QGroupBox,
- QSpacerItem,
- QSizePolicy,
- QProgressBar,
- QMessageBox,
- QAbstractButton
-)
-from PySide6.QtCore import QSettings, Qt, QThread, Signal, QTimer, QTimer
+from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout
+from PySide6.QtCore import QSettings, Qt, QThread, Signal, QTimer
from PySide6.QtGui import QFont
+from qfluentwidgets import *
from darkdetect import isDark
from update.update_manager import UpdateManager
from update.download_update import download_and_apply_update
from qframelesswindow.utils import getSystemAccentColor
-from qfluentwidgets import *
-import time
from con import CON
+
+
class CheckUpdateThread(QThread):
check_finished = Signal(dict)
@@ -58,13 +44,12 @@ def __init__(self, download_url, version, include_prerelease=False):
self.include_prerelease = include_prerelease
self.is_cancelled = False
self.progress_callback = None
- self.downloader = None # Initialize downloader attribute
+ self.downloader = None
def run(self):
try:
print(f"DownloadThread started, is_cancelled: {self.is_cancelled}")
- # Check if cancelled before starting download
if self.is_cancelled:
print("DownloadThread: cancelled before download")
result = {
@@ -74,7 +59,6 @@ def run(self):
self.finished.emit(result)
return
- # Create a simple progress callback that emits signals
def progress_callback(progress, downloaded, total_size):
if not self.is_cancelled:
progress_data = {
@@ -84,16 +68,13 @@ def progress_callback(progress, downloaded, total_size):
}
self.progress_updated.emit(progress_data)
- # Set the progress callback
self.progress_callback = progress_callback
- # Create update info dictionary
update_info = {
"download_url": self.download_url,
"latest_version": self.version
}
- # Create UpdateDownloader instance for cancellation
from update.download_update import UpdateDownloader
import tempfile
temp_dir = tempfile.mkdtemp(prefix="update_")
@@ -102,21 +83,18 @@ def progress_callback(progress, downloaded, total_size):
self.downloader = UpdateDownloader(
download_url=self.download_url,
target_directory=temp_dir,
- max_threads=64 # Use 64 threads
+ max_threads=64
)
- # Download update only (don't apply it)
print("DownloadThread: starting download")
result = self.downloader.download_update(self.version, progress_callback)
print(f"DownloadThread: download completed with status: {result.get('status')}")
- # Check if the download was cancelled
if result.get("status") == "cancelled":
print("DownloadThread: result status is cancelled")
self.finished.emit(result)
return
- # Check if cancelled during download
if self.is_cancelled or (self.downloader and hasattr(self.downloader, '_cancelled') and self.downloader._cancelled):
print("DownloadThread: cancelled during download")
result = {
@@ -127,7 +105,6 @@ def progress_callback(progress, downloaded, total_size):
self.finished.emit(result)
return
- # Check again if cancelled
if self.is_cancelled:
print("DownloadThread: cancelled after download")
result = {
@@ -138,7 +115,6 @@ def progress_callback(progress, downloaded, total_size):
self.finished.emit(result)
return
- # Add downloader to result for cleanup
result["downloader"] = self.downloader
result["temp_dir"] = temp_dir
print("DownloadThread: emitting success result")
@@ -146,10 +122,15 @@ def progress_callback(progress, downloaded, total_size):
except Exception as e:
print(f"DownloadThread exception: {e}")
+ import shutil
+ try:
+ shutil.rmtree(temp_dir)
+ except Exception as cleanup_error:
+ print(f"Failed to clean up temp directory: {cleanup_error}")
+
error_result = {
"status": "error",
- "message": f"Download failed: {str(e)}",
- "temp_dir": temp_dir
+ "message": f"Download failed: {str(e)}"
}
self.finished.emit(error_result)
@@ -162,214 +143,192 @@ def progress_callback(progress, downloaded, total_size):
self.finished.emit(error_result)
def cancel(self):
- """Cancel the download"""
+ """Cancel download"""
print("DownloadThread: cancel() called")
self.is_cancelled = True
- # Cancel the downloader
if self.downloader:
print("DownloadThread: cancelling downloader")
self.downloader.cancel()
- # Wait a moment to ensure cancellation is complete
import time
time.sleep(0.1)
- # Request thread exit
self.quit()
- # Wait for thread to completely stop
- if not self.wait(1000): # Wait up to 1 second
+ if not self.wait(1000):
print("Warning: DownloadThread did not stop within 1 second, forcing termination")
self.terminate()
- self.wait(500) # Wait another 500ms to ensure termination is complete
+ self.wait(500)
-class UpdateDialog(QWidget):
- __version__ = "2.0.0RC1"
- def __init__(self):
- super().__init__()
- # Remove SystemThemeListener to avoid thread issues
- # self.themeListener = SystemThemeListener(self)
-
-
- self.setWindowTitle("Update Settings")
+class UpdateSettingsWidget(QWidget):
+ """Update settings widget using qfluentwidgets SettingCard components"""
+
+ __version__ = "2.1.0A6"
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
self.update_manager = UpdateManager(self.__version__)
self.check_thread = None
self.download_thread = None
-
- # Detect current version type
- self._detect_current_version_type()
+ self.current_update_info = None
- self.init_ui()
+ self._detect_current_version_type()
+ self.setup_ui()
self.load_settings()
- self.connect_auto_save_signals()
-
- def _detect_current_version_type(self):
- """检测当前版本类型,如果是Alpha或Deepdev版本,则限制可用通道"""
- try:
- # 解析当前版本
- version_info = self.update_manager._parse_version(self.__version__)
- _, _, _, current_tag, _ = version_info
-
- # 检查是否为Alpha或Deepdev版本
- self.is_alpha_version = (current_tag == 'A')
- self.is_deepdev_version = (current_tag == 'D')
- self.is_internal_version = self.is_alpha_version or self.is_deepdev_version
-
- if self.is_internal_version:
- print(f"检测到内部版本: {self.__version__} (标签: {current_tag})")
-
- except Exception as e:
- print(f"版本检测失败: {e}")
- self.is_internal_version = False
- self.is_alpha_version = False
- self.is_deepdev_version = False
+ self.connect_signals()
-
- def init_ui(self):
-
+ def setup_ui(self):
+ """Setup UI layout"""
main_layout = QVBoxLayout(self)
- main_layout.setContentsMargins(25, 25, 25, 25)
- main_layout.setSpacing(20)
-
- update_group = QGroupBox("Update Settings")
- update_layout = QVBoxLayout()
- update_layout.setContentsMargins(25, 25, 25, 25)
- update_layout.setSpacing(20)
-
- # 添加顶部间距
- update_layout.addSpacerItem(QSpacerItem(0, 15, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed))
-
- # 添加版本类型选择器
- prerelease_container = QHBoxLayout()
- prerelease_container.setSpacing(10)
-
- self.prerelease_type_label = QLabel("Update channel:")
- prerelease_container.addWidget(self.prerelease_type_label)
-
- self.prerelease_type_combo = ModelComboBox()
- setCustomStyleSheet(self.prerelease_type_combo,CON.qss_combo_2,CON.qss_combo_2)
- # 根据版本类型设置可用通道
+ main_layout.setContentsMargins(0, 0, 0, 0)
+ main_layout.setSpacing(0)
+
+ # Create scroll area
+ self.scroll_area = SingleDirectionScrollArea(orient=Qt.Orientation.Vertical)
+ self.scroll_area.setWidgetResizable(True)
+ self.scroll_area.enableTransparentBackground()
+
+ # Create scroll content widget
+ scroll_content = QWidget()
+ scroll_content.setObjectName("scroll_content")
+ scroll_layout = QVBoxLayout(scroll_content)
+ scroll_layout.setContentsMargins(30, 30, 30, 30)
+ scroll_layout.setSpacing(20)
+
+ # Create setting card groups
+ self.update_group = SettingCardGroup("Update Configuration", scroll_content)
+
+ # Determine available channels based on version type
+ available_channels = ["Stable"]
if self.is_internal_version:
- # 内部版本只显示对应的通道和稳定版
if self.is_alpha_version:
- self.prerelease_type_combo.addItems([ "Alpha"])
+ available_channels = ["Stable", "Alpha"]
print("Alpha版本:只显示Alpha通道")
elif self.is_deepdev_version:
- self.prerelease_type_combo.addItems(["Deepdev"])
+ available_channels = ["Stable", "Deepdev"]
print("Deepdev版本:只显示Deepdev通道")
else:
- # 普通版本显示所有通道
- self.prerelease_type_combo.addItems(["Stable", "RC (Release Candidate)", "Beta", "Deepdev", "Alpha"])
-
- self.prerelease_type_combo.setFixedWidth(200)
- prerelease_container.addWidget(self.prerelease_type_combo)
-
- prerelease_container.addStretch()
- update_layout.addLayout(prerelease_container)
-
- self.update_status_label = QLabel("Ready to check for updates.")
- self.update_status_label.setMinimumHeight(60)
- self.update_status_label.setMinimumWidth(550)
+ available_channels = ["Stable", "RC (Release Candidate)", "Beta", "Deepdev", "Alpha"]
+
+ channel_layout = QHBoxLayout()
+ channel_label = BodyLabel("Update Channel:")
+ channel_label.setFixedWidth(120)
+ self.channel_combo = ComboBox()
+ self.channel_combo.addItems(available_channels)
+ self.channel_combo.setFixedWidth(250)
+ channel_layout.addWidget(channel_label)
+ channel_layout.addWidget(self.channel_combo)
+ channel_layout.addStretch()
+ self.update_group.vBoxLayout.addLayout(channel_layout)
+
+ channel_info = CaptionLabel("Select update channel to receive updates from")
+ self.update_group.vBoxLayout.addWidget(channel_info)
+
+ # Create check for updates card
+ self.check_update_card = PrimaryPushSettingCard(
+ "Check for Updates",
+ FluentIcon.SYNC,
+ "Check for new updates"
+ )
+ self.check_update_card.clicked.connect(self.check_for_updates)
+
+ # Create download update card (initially disabled)
+ self.download_update_card = PrimaryPushSettingCard(
+ "Download Update",
+ FluentIcon.DOWNLOAD,
+ "Download and install latest update"
+ )
+ self.download_update_card.clicked.connect(self.download_update)
+ self.download_update_card.setEnabled(False)
+
+ # Create restart application card (initially disabled)
+ self.restart_card = PrimaryPushSettingCard(
+ "Restart Application",
+ FluentIcon.SYNC,
+ "Restart to apply update"
+ )
+ self.restart_card.clicked.connect(self.restart_application)
+ self.restart_card.setEnabled(False)
+
+ # Add cards to group
+ self.update_group.addSettingCards([
+ self.check_update_card,
+ self.download_update_card,
+ self.restart_card
+ ])
+
+ # Add update status and progress to group
+ self.update_status_label = BodyLabel("Ready to check for updates.")
self.update_status_label.setWordWrap(True)
- #self.update_status_label.setStyleSheet("QLabel { padding: 8px; background-color: #f8f9fa; border-radius: 5px; }")
- update_layout.addWidget(self.update_status_label)
+ self.update_group.vBoxLayout.addWidget(self.update_status_label)
- # 更新内容显示区域
+ # Release content browser
self.release_content_browser = TextBrowser()
self.release_content_browser.setMinimumHeight(150)
self.release_content_browser.setVisible(False)
- update_layout.addWidget(self.release_content_browser)
+ self.update_group.vBoxLayout.addWidget(self.release_content_browser)
- # 下载进度条
+ # Progress bars
self.progress_bar = IndeterminateProgressBar()
self.progress_bar.setVisible(False)
- update_layout.addWidget(self.progress_bar)
+ self.update_group.vBoxLayout.addWidget(self.progress_bar)
- # 实际下载进度条
self.download_progress_bar = ProgressBar()
self.download_progress_bar.setRange(0, 100)
self.download_progress_bar.setValue(0)
self.download_progress_bar.setVisible(False)
- update_layout.addWidget(self.download_progress_bar)
+ self.update_group.vBoxLayout.addWidget(self.download_progress_bar)
- # 进度标签(显示百分比、下载大小、速度等信息)
- self.progress_label = QLabel("0%")
+ # Progress label
+ self.progress_label = BodyLabel("0%")
self.progress_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
font = QFont()
font.setPointSize(10)
font.setBold(True)
self.progress_label.setFont(font)
self.progress_label.setVisible(False)
- update_layout.addWidget(self.progress_label)
-
- # 按钮容器
- button_container = QHBoxLayout()
- button_container.setSpacing(15)
-
- self.update_button = PrimaryPushButton("Check for Updates")
- self.update_button.setFixedSize(180, 60)
- setCustomStyleSheet(self.update_button, CON.qss_debug, CON.qss_debug)
- self.update_button.clicked.connect(self.check_for_updates)
-
- self.download_button =PrimaryPushButton("Download Update")
- self.download_button.setFixedSize(180, 60)
- setCustomStyleSheet(self.download_button, CON.qss_debug, CON.qss_debug)
- self.download_button.clicked.connect(self.download_update)
- self.download_button.setVisible(False)
- self.download_button.setEnabled(False)
-
- self.restart_button = PrimaryPushButton("Restart Application")
- self.restart_button.setFixedSize(180, 60)
- setCustomStyleSheet(self.restart_button, CON.qss_debug, CON.qss_debug)
- self.restart_button.clicked.connect(self.restart_application)
- self.restart_button.setVisible(False)
- self.restart_button.setEnabled(False)
-
- button_container.addStretch()
- button_container.addWidget(self.update_button)
- button_container.addWidget(self.download_button)
- button_container.addWidget(self.restart_button)
- button_container.addStretch()
- update_layout.addLayout(button_container)
-
- # 添加底部间距
- update_layout.addSpacerItem(QSpacerItem(0, 15, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed))
-
- update_group.setLayout(update_layout)
- main_layout.addWidget(update_group)
-
- self.setLayout(main_layout)
-
-
+ self.update_group.vBoxLayout.addWidget(self.progress_label)
- # 设置按钮字体
- font = QFont()
- font.setPointSize(12)
- font.setBold(True)
- self.update_button.setFont(font)
- self.download_button.setFont(font)
- self.restart_button.setFont(font)
-
- # 设置标签字体
- label_font = QFont()
- label_font.setPointSize(11)
- self.update_status_label.setFont(label_font)
-
- # 设置标签字体
- label_font = QFont()
- label_font.setPointSize(11)
- self.prerelease_type_label.setFont(label_font)
-
-
-
-
-
+ # Add groups to scroll layout
+ scroll_layout.addWidget(self.update_group)
+ scroll_layout.addStretch()
+
+ # Set scroll content
+ self.scroll_area.setWidget(scroll_content)
+
+ # Add scroll area to main layout
+ main_layout.addWidget(self.scroll_area)
+
+ def connect_signals(self):
+ """Connect signals"""
+ self.channel_combo.currentIndexChanged.connect(self.on_update_channel_changed)
+
+ def _detect_current_version_type(self):
+ """Detect current version type"""
+ try:
+ version_info = self.update_manager._parse_version(self.__version__)
+ _, _, _, current_tag, _ = version_info
+
+ self.is_alpha_version = (current_tag == 'A')
+ self.is_deepdev_version = (current_tag == 'D')
+ self.is_internal_version = self.is_alpha_version or self.is_deepdev_version
+
+ if self.is_internal_version:
+ print(f"检测到内部版本: {self.__version__} (标签: {current_tag})")
+
+ except Exception as e:
+ print(f"版本检测失败: {e}")
+ self.is_internal_version = False
+ self.is_alpha_version = False
+ self.is_deepdev_version = False
+
def load_settings(self):
+ """Load settings from QSettings"""
settings = QSettings("MyCompany", "ConverterApp")
prerelease_type = settings.value("update/prerelease_type", "stable", type=str)
- # 设置版本类型选择器
- type_index = 0 # 默认为 "Stable"
+
+ type_index = 0
if prerelease_type == "rc":
type_index = 1
elif prerelease_type == "beta":
@@ -378,16 +337,15 @@ def load_settings(self):
type_index = 3
elif prerelease_type == "alpha":
type_index = 4
- self.prerelease_type_combo.setCurrentIndex(type_index)
-
+
+ self.channel_combo.setCurrentIndex(type_index)
+
def save_settings(self):
- """保存设置"""
+ """Save settings to QSettings"""
try:
- # 保存更新通道设置
- prerelease_type = "stable" # 默认
+ prerelease_type = "stable"
- # 根据选择的索引确定类型
- current_index = self.prerelease_type_combo.currentIndex()
+ current_index = self.channel_combo.currentIndex()
if current_index == 1:
prerelease_type = "rc"
elif current_index == 2:
@@ -404,43 +362,25 @@ def save_settings(self):
except Exception as e:
print(f"Error saving settings: {e}")
- def connect_auto_save_signals(self):
- """Connect UI controls to auto-save functionality"""
- # Connect combo box to auto-save
- self.prerelease_type_combo.currentIndexChanged.connect(self.on_update_channel_changed)
-
- def auto_save_settings(self):
- """Auto-save settings immediately upon change"""
- try:
- # Save settings immediately
- self.save_settings()
- except Exception as e:
- print(f"Error in auto_save_settings: {e}")
-
def on_update_channel_changed(self, index):
- """处理更新通道选择变化"""
- # 自动保存设置
- self.auto_save_settings()
+ """Handle update channel change"""
+ self.save_settings()
def _get_update_check_params(self):
- """获取更新检查参数"""
- prerelease_type = "stable" # 默认稳定版
+ """Get update check parameters"""
+ prerelease_type = "stable"
- current_index = self.prerelease_type_combo.currentIndex()
- current_text = self.prerelease_type_combo.currentText()
+ current_index = self.channel_combo.currentIndex()
+ current_text = self.channel_combo.currentText()
- # 根据版本类型调整索引映射
if self.is_internal_version:
if self.is_alpha_version:
- # Alpha版本:索引0=Stable, 索引1=Alpha
if current_index == 1:
prerelease_type = "alpha"
elif self.is_deepdev_version:
- # Deepdev版本:索引0=Stable, 索引1=Deepdev
if current_index == 1:
prerelease_type = "deepdev"
else:
- # 普通版本:标准索引映射
if current_index == 1:
prerelease_type = "rc"
elif current_index == 2:
@@ -450,42 +390,37 @@ def _get_update_check_params(self):
elif current_index == 4:
prerelease_type = "alpha"
- # 根据通道类型决定是否包含预发布版本
include_prerelease = (prerelease_type != "stable")
return include_prerelease, prerelease_type if include_prerelease else None
-
+
def check_for_updates(self):
- """检查更新"""
+ """Check for updates"""
self.update_status_label.setText("Checking for updates...")
setThemeColor(getSystemAccentColor(), save=False)
- self.update_button.setEnabled(False)
+ self.check_update_card.setEnabled(False)
- # 隐藏TextBrowser、下载按钮和重启按钮
self.release_content_browser.setVisible(False)
- self.download_button.setVisible(False)
- self.restart_button.setVisible(False)
+ self.download_update_card.setEnabled(False)
+ self.restart_card.setEnabled(False)
- # 显示进度条并设置主题色
self.progress_bar.setVisible(True)
self.progress_bar.resume()
self.progress_bar.start()
- QApplication.processEvents() # 确保界面更新
- # 获取更新检查参数
- include_prerelease, prerelease_type = self._get_update_check_params()
+ from PySide6.QtWidgets import QApplication
+ QApplication.processEvents()
- # Auto-save settings immediately when checking for updates
- self.auto_save_settings()
+ include_prerelease, prerelease_type = self._get_update_check_params()
+ self.save_settings()
- # 启动检查更新线程
self.check_thread = CheckUpdateThread(self.update_manager, include_prerelease, prerelease_type if include_prerelease else None)
self.check_thread.check_finished.connect(self.on_check_finished)
self.check_thread.start()
def on_check_finished(self, result):
+ """Handle check finished"""
if result["status"] == "update_available":
- # 获取版本类型信息
version_type = ""
if "version_info" in result:
version_tuple = result["version_info"]
@@ -494,11 +429,9 @@ def on_check_finished(self, result):
version_type = f" ({version_type})"
self.update_status_label.setText(f"✅ {result['message']}\n\nVersion: {result['latest_version']}{version_type}")
- self.download_button.setVisible(True)
- self.download_button.setEnabled(True)
+ self.download_update_card.setEnabled(True)
self.current_update_info = result
- # 显示更新内容
if result.get("release_body"):
self.release_content_browser.setMarkdown(result["release_body"])
self.release_content_browser.setVisible(True)
@@ -506,23 +439,23 @@ def on_check_finished(self, result):
self.release_content_browser.setVisible(False)
elif result["status"] == "error":
self.update_status_label.setText(f"❌ Check failed: {result['message']}")
- self.download_button.setVisible(False)
+ self.download_update_card.setEnabled(False)
self.release_content_browser.setVisible(False)
else:
self.update_status_label.setText(f"ℹ️ {result['message']}")
- self.download_button.setVisible(False)
+ self.download_update_card.setEnabled(False)
self.release_content_browser.setVisible(False)
self.progress_bar.pause()
self.progress_bar.setVisible(False)
- self.update_button.setEnabled(True)
+ self.check_update_card.setEnabled(True)
def download_update(self):
+ """Download update"""
if hasattr(self, 'current_update_info'):
- self.download_button.setEnabled(False)
- self.update_button.setEnabled(False)
+ self.download_update_card.setEnabled(False)
+ self.check_update_card.setEnabled(False)
- # 隐藏TextBrowser
self.release_content_browser.setVisible(False)
self.progress_bar.setVisible(True)
@@ -530,18 +463,14 @@ def download_update(self):
self.download_progress_bar.setVisible(False)
self.update_status_label.setText("Download in progress...")
- # 显示进度标签
self.progress_label.setText("0%")
self.progress_label.setVisible(True)
- # 获取下载URL和版本信息
download_url = self.current_update_info.get("download_url")
latest_version = self.current_update_info.get("latest_version")
- # 获取预发布设置
include_prerelease, prerelease_type = self._get_update_check_params()
- # 重置下载计时器
if hasattr(self, '_download_start_time'):
delattr(self, '_download_start_time')
if hasattr(self, '_last_downloaded'):
@@ -549,388 +478,106 @@ def download_update(self):
if hasattr(self, '_last_time'):
delattr(self, '_last_time')
- # 使用新的DownloadThread类
self.download_thread = DownloadThread(download_url, latest_version, include_prerelease)
self.download_thread.progress_updated.connect(self.on_progress_updated)
self.download_thread.finished.connect(self.on_download_finished)
self.download_thread.start()
- # 将下载按钮改为取消按钮
- self.download_button.setText("Cancel Download")
- self.download_button.setEnabled(True)
- self.download_button.clicked.disconnect()
- self.download_button.clicked.connect(self.cancel_download)
+ self.download_update_card.setText("Cancel Download")
+ self.download_update_card.setEnabled(True)
+ self.download_update_card.clicked.disconnect()
+ self.download_update_card.clicked.connect(self.cancel_download)
def cancel_download(self):
- """取消下载"""
+ """Cancel download"""
if hasattr(self, 'download_thread') and self.download_thread is not None and self.download_thread.isRunning():
self.download_thread.cancel()
- self.download_button.setEnabled(False)
+ self.download_update_card.setEnabled(False)
self.update_status_label.setText("Cancelling download...")
- # 设置一个定时器来检查线程是否已停止
def check_thread_stopped():
- # 检查线程是否已停止
if self.download_thread is None:
- # 线程已被清理,直接返回
return
if not self.download_thread.isRunning():
- # 线程已停止,显示取消状态
self.show_cancelled_state()
else:
- # 线程仍在运行,继续等待
QTimer.singleShot(500, check_thread_stopped)
- # 开始检查线程状态
QTimer.singleShot(500, check_thread_stopped)
- def start_swing_animation(self):
- """启动左右摆动动画"""
- pass
-
- def update_swing_animation(self):
- """更新摆动动画"""
- pass
+ def show_cancelled_state(self):
+ """Show cancelled state"""
+ self.update_status_label.setText("Download cancelled")
+ self.progress_bar.setVisible(False)
+ self.download_progress_bar.setVisible(False)
+ self.progress_label.setVisible(False)
+ self.check_update_card.setEnabled(True)
+ self.download_update_card.setText("Download Update")
+ self.download_update_card.clicked.disconnect()
+ self.download_update_card.clicked.connect(self.download_update)
def on_progress_updated(self, progress_data):
- """处理下载进度更新"""
+ """Handle progress update"""
if not progress_data:
return
-
- # 从progress_data中获取进度信息
+
progress = progress_data.get("progress", 0)
- downloaded_bytes = progress_data.get("downloaded", 0)
- total_bytes = progress_data.get("total", 0)
-
- # 更新进度条显示实际进度
- if progress > 0:
- # 显示实际进度条,隐藏不确定进度条
- self.progress_bar.setVisible(False)
- self.download_progress_bar.setVisible(True)
- self.download_progress_bar.setValue(progress)
-
- # 计算下载速度和时间估计
- current_time = time.time()
- if not hasattr(self, '_download_start_time'):
- self._download_start_time = current_time
- self._last_downloaded = 0
- self._last_time = current_time
-
- # 计算平均速度和剩余时间
- elapsed_time = current_time - self._download_start_time
- if elapsed_time > 1.0: # 至少1秒后再计算速度
- # 计算平均下载速度
- avg_download_speed = downloaded_bytes / elapsed_time # bytes per second
-
- # 计算剩余时间
- remaining_bytes = total_bytes - downloaded_bytes
- if avg_download_speed > 0 and total_bytes > 0:
- remaining_time = remaining_bytes / avg_download_speed
- # 格式化剩余时间
- if remaining_time > 3600:
- eta_str = f"{remaining_time/3600:.1f}h"
- elif remaining_time > 60:
- eta_str = f"{remaining_time/60:.1f}m"
- else:
- eta_str = f"{remaining_time:.0f}s"
- else:
- eta_str = "计算中"
-
- # 格式化文件大小
- def format_size(bytes_size):
- if bytes_size >= 1024*1024*1024:
- return f"{bytes_size/(1024*1024*1024):.2f} GB"
- elif bytes_size >= 1024*1024:
- return f"{bytes_size/(1024*1024):.2f} MB"
- elif bytes_size >= 1024:
- return f"{bytes_size/1024:.2f} KB"
- else:
- return f"{bytes_size} B"
-
- # 更新数字进度标签
- if hasattr(self, 'progress_label'):
- downloaded_str = format_size(downloaded_bytes)
- total_str = format_size(total_bytes)
- speed_str = format_size(avg_download_speed) + "/s"
-
- self.progress_label.setText(
- f"{progress}% - {downloaded_str}/{total_str} - {speed_str} - ETA: {eta_str}"
- )
+ downloaded = progress_data.get("downloaded", 0)
+ total = progress_data.get("total", 0)
- if progress == 100:
- self.progress_bar.pause()
+ self.download_progress_bar.setVisible(True)
+ self.download_progress_bar.setValue(int(progress))
- def on_download_finished(self, result):
- # 停止进度条
- if result["status"] == "success":
- # 下载成功,立即开始应用更新
- self.progress_bar.pause()
- self.progress_bar.setVisible(False)
- self.download_progress_bar.setVisible(False)
-
- # 隐藏数字进度标签
- if hasattr(self, 'progress_label'):
- self.progress_label.setVisible(False)
-
- # 清理下载计时器变量
- if hasattr(self, '_download_start_time'):
- delattr(self, '_download_start_time')
- if hasattr(self, '_last_downloaded'):
- delattr(self, '_last_downloaded')
- if hasattr(self, '_last_time'):
- delattr(self, '_last_time')
-
- # 清理下载线程
- if hasattr(self, 'download_thread') and self.download_thread:
- if self.download_thread.downloader:
- self.download_thread.downloader.cleanup()
-
- # 确保线程完全停止后再设置为None
- if self.download_thread.isRunning():
- self.download_thread.quit()
- self.download_thread.wait(2000) # 最多等待2秒
-
- self.download_thread = None
-
- # 保存更新信息
- self.update_result = result
-
- # 显示应用更新进度
- self.update_status_label.setText("Applying updates...")
-
- # 使用IndeterminateProgressBar显示应用更新进度
- self.progress_bar.setVisible(True)
- self.progress_bar.start()
-
- # 立即执行更新应用
- self.apply_update()
-
+ if total > 0:
+ downloaded_mb = downloaded / (1024 * 1024)
+ total_mb = total / (1024 * 1024)
+ self.progress_label.setText(f"{progress:.1f}% - {downloaded_mb:.1f} MB / {total_mb:.1f} MB")
else:
- # 下载失败或取消
- if result["status"] == "cancelled":
- # 先将进度条设置为100%
- self.download_progress_bar.setValue(100)
- self.progress_label.setText("100%")
- QApplication.processEvents() # 确保界面更新
-
- # 短暂延迟后显示取消状态
- QTimer.singleShot(500, lambda: self.show_cancelled_state())
- else:
- # 确保错误消息正确处理非ASCII字符
- try:
- error_message = str(result['message'])
- self.update_status_label.setText(f"❌ 下载失败: {error_message}")
- except Exception as e:
- # 如果出现编码问题,显示基本错误信息
- self.update_status_label.setText(f"❌ 下载失败: 处理错误消息时出现编码问题")
-
- self.progress_bar.setVisible(False)
- self.download_progress_bar.setVisible(False)
-
- # 隐藏数字进度标签
- if hasattr(self, 'progress_label'):
- self.progress_label.setVisible(False)
-
- # 清理下载计时器变量
- if hasattr(self, '_download_start_time'):
- delattr(self, '_download_start_time')
- if hasattr(self, '_last_downloaded'):
- delattr(self, '_last_downloaded')
- if hasattr(self, '_last_time'):
- delattr(self, '_last_time')
-
- # 恢复下载按钮
- self.download_button.setText("Download Update")
- self.download_button.clicked.disconnect()
- self.download_button.clicked.connect(self.download_update)
- self.download_button.setEnabled(True)
- self.download_button.setVisible(True)
-
- self.update_button.setEnabled(True)
+ self.progress_label.setText(f"{progress:.1f}%")
- def show_cancelled_state(self):
- """显示取消下载的状态,恢复到初始形态"""
- self.update_status_label.setText("Ready to check for updates.")
-
- # 隐藏进度条
+ def on_download_finished(self, result):
+ """Handle download finished"""
self.progress_bar.setVisible(False)
self.download_progress_bar.setVisible(False)
+ self.progress_label.setVisible(False)
- # 隐藏数字进度标签
- if hasattr(self, 'progress_label'):
- self.progress_label.setVisible(False)
-
- # 隐藏发布内容浏览器
- self.release_content_browser.setVisible(False)
-
- # 清理下载计时器变量
- if hasattr(self, '_download_start_time'):
- delattr(self, '_download_start_time')
- if hasattr(self, '_last_downloaded'):
- delattr(self, '_last_downloaded')
- if hasattr(self, '_last_time'):
- delattr(self, '_last_time')
-
- # Clean up downloader and thread
- if hasattr(self, 'download_thread') and self.download_thread:
- if self.download_thread.downloader:
- self.download_thread.downloader.cleanup()
-
- # 确保线程完全停止后再设置为None
- if self.download_thread.isRunning():
- self.download_thread.quit()
- self.download_thread.wait(2000) # 最多等待2秒
-
- self.download_thread = None
-
- # 隐藏下载按钮,显示检查更新按钮
- self.download_button.setVisible(False)
- self.download_button.setEnabled(False)
-
- # 启用检查更新按钮
- self.update_button.setEnabled(True)
-
- def apply_update(self):
- """应用更新"""
- if hasattr(self, 'update_result'):
- # 新的下载流程已经自动应用了更新,所以这里只需要显示成功消息
- if self.update_result.get("status") == "success":
- # 更新状态显示
- self.update_status_label.setText("✅ Update applied successfully! Please restart the application.")
-
- # 显示重启按钮
- self.restart_button.setVisible(True)
- self.restart_button.setEnabled(True)
-
- # 停止进度条
- self.progress_bar.pause()
- self.progress_bar.setVisible(False)
- else:
- # 处理错误情况
- error_message = self.update_result.get("message", "Unknown error")
- self.update_status_label.setText(f"❌ Update failed: {error_message}")
-
- # 停止进度条
- self.progress_bar.pause()
- self.progress_bar.setVisible(False)
- else:
- print("❌ update_result属性不存在")
- self.update_status_label.setText("❌ Update information lost, please restart manually")
-
-
-
- def __del__(self):
- """析构函数,确保所有线程都被正确清理"""
- try:
- # 清理下载线程
- if hasattr(self, 'download_thread') and self.download_thread is not None:
- if self.download_thread.isRunning():
- self.download_thread.quit()
- if not self.download_thread.wait(1000):
- self.download_thread.terminate()
- self.download_thread.wait(500)
-
- # 清理下载器
- if hasattr(self.download_thread, 'downloader') and self.download_thread.downloader:
- self.download_thread.downloader.cleanup()
-
- self.download_thread = None
-
- # 清理检查更新线程
- if hasattr(self, 'check_thread') and self.check_thread is not None:
- if self.check_thread.isRunning():
- self.check_thread.quit()
- if not self.check_thread.wait(1000):
- self.check_thread.terminate()
- self.check_thread.wait(500)
-
- self.check_thread = None
- except:
- # 在析构函数中忽略所有异常
- pass
-
- def closeEvent(self, event):
- """重写窗口关闭事件,下载时阻止关闭"""
- # 检查是否有下载正在进行
- if hasattr(self, 'download_thread') and self.download_thread is not None and self.download_thread.isRunning():
- # 下载正在进行,显示提示并阻止关闭
- reply = QMessageBox.question(
- self,
- "下载进行中",
- "正在下载更新,确定要关闭窗口吗?\n关闭窗口将取消下载。",
- QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
- QMessageBox.StandardButton.No
- )
-
- if reply == QMessageBox.StandardButton.Yes:
- # 用户确认关闭,先取消下载
- self.cancel_download()
-
- # 等待更长时间确保线程完全停止
- max_wait_time = 5000 # 最多等待5秒
- wait_step = 500 # 每次等待500ms
- total_waited = 0
-
- while self.download_thread and self.download_thread.isRunning() and total_waited < max_wait_time:
- QApplication.processEvents() # 处理待处理的事件
- self.download_thread.wait(wait_step) # 等待一小段时间
- total_waited += wait_step
-
- # 如果线程仍在运行,强制终止
- if self.download_thread and self.download_thread.isRunning():
- self.download_thread.terminate()
- self.download_thread.wait(1000) # 再等待1秒
-
- # 清理下载器
- if self.download_thread and self.download_thread.downloader:
- self.download_thread.downloader.cleanup()
-
- self.download_thread = None
-
- # 允许关闭窗口
- event.accept()
- else:
- # 用户取消关闭,阻止窗口关闭
- event.ignore()
+ if result["status"] == "success":
+ self.update_status_label.setText("✅ Download completed successfully!")
+ self.restart_card.setEnabled(True)
+ self.check_update_card.setEnabled(True)
+ self.download_update_card.setText("Download Update")
+ self.download_update_card.clicked.disconnect()
+ self.download_update_card.clicked.connect(self.download_update)
+ elif result["status"] == "cancelled":
+ self.show_cancelled_state()
else:
- # 没有下载在进行,检查是否有检查更新线程
- if hasattr(self, 'check_thread') and self.check_thread is not None and self.check_thread.isRunning():
- # 检查更新正在进行,取消它
- self.check_thread.quit()
- if not self.check_thread.wait(1000): # 等待最多1秒
- self.check_thread.terminate()
- self.check_thread.wait(500) # 再等待500ms
- self.check_thread = None
-
- # 允许正常关闭
- event.accept()
+ self.update_status_label.setText(f"❌ Download failed: {result.get('message', 'Unknown error')}")
+ self.check_update_card.setEnabled(True)
+ self.download_update_card.setText("Download Update")
+ self.download_update_card.clicked.disconnect()
+ self.download_update_card.clicked.connect(self.download_update)
def restart_application(self):
- """重启应用程序"""
+ """Restart application"""
+ self.update_status_label.setText("Restarting application...")
+
+ import subprocess
+ import sys
+
try:
- # 执行重启脚本
- restart_script_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), "update", "restart.command")
-
- if os.path.exists(restart_script_path):
- # 执行重启脚本
- os.system(f"'{restart_script_path}' &")
- print(f"✅ 重启脚本已执行: {restart_script_path}")
-
- # 关闭当前应用程序
- QApplication.quit()
-
- else:
- print(f"❌ 重启脚本不存在: {restart_script_path}")
- self.update_status_label.setText("❌ Restart script not found, please restart manually")
-
+ subprocess.Popen([sys.executable] + sys.argv)
+ from PySide6.QtWidgets import QApplication
+ QApplication.instance().quit()
except Exception as e:
- print(f"❌ 执行重启脚本时出错: {e}")
- self.update_status_label.setText(f"❌ Restart failed: {e}")
+ self.update_status_label.setText(f"Failed to restart: {e}")
-def main():
+
+if __name__ == "__main__":
+ from PySide6.QtWidgets import QApplication
+
app = QApplication(sys.argv)
- window = UpdateDialog()
- window.resize(500, 350) # 扩大窗口大小以容纳更多内容
- window.show()
- sys.exit(app.exec())
\ No newline at end of file
+ widget = UpdateSettingsWidget()
+ widget.resize(800, 600)
+ widget.show()
+ sys.exit(app.exec())
diff --git a/support/GUI/arc_support.py b/support/GUI/arc_support.py
new file mode 100644
index 0000000..9a910cb
--- /dev/null
+++ b/support/GUI/arc_support.py
@@ -0,0 +1,253 @@
+import os
+from PySide6.QtCore import Qt, QSize, Signal
+from PySide6.QtWidgets import QFrame, QVBoxLayout, QLabel, QFileDialog
+from PySide6.QtGui import QDragEnterEvent, QDropEvent, QPalette, QPixmap
+
+
+class BatchDropZoneWidget(QFrame):
+ """Custom widget for drag and drop archive file selection"""
+ files_dropped = Signal(list) # Signal for multiple archive files dropped
+
+ def __init__(self, placeholder_text="Drag archive files here or click to browse", parent=None):
+ super().__init__(parent)
+ self.setAcceptDrops(True)
+ self.setMinimumHeight(100) # 增加到100确保足够空间
+ self.setFixedHeight(100) # 设置固定高度防止缩小
+ self.setMinimumWidth(200) # 设置最小宽度
+ self.is_dark_mode = False # Track current theme mode
+
+ # Define supported archive formats
+ self.supported_formats = {
+ '.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.xz', '.cab', '.iso', '.arj', '.ace', '.lzh', '.lha'
+ }
+
+ layout = QVBoxLayout(self)
+ layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ layout.setContentsMargins(10, 10, 10, 10) # 设置内边距防止内容紧贴边框
+
+ self.icon_label = QLabel("📁")
+ self.icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.icon_label.setStyleSheet("font-size: 20px;")
+
+ self.text_label = QLabel(placeholder_text)
+ self.text_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.text_label.setStyleSheet("color: #666; font-size: 12px;")
+ self.text_label.setWordWrap(True)
+
+ layout.addWidget(self.icon_label)
+ layout.addWidget(self.text_label)
+
+ self.drag_over = False
+
+ # Click to browse
+ self.mousePressEvent = self.browse_files
+
+ # Apply initial light theme style after all widgets are created
+ self._apply_light_theme_style()
+
+ def sizeHint(self):
+ """Return fixed size hint to prevent resizing"""
+ return super().sizeHint()
+
+ def minimumSizeHint(self):
+ """Return minimum size hint to prevent shrinking"""
+ return QSize(200, 100)
+
+ def set_theme(self, is_dark_mode):
+ """Update the theme of the drag and drop area"""
+ self.is_dark_mode = is_dark_mode
+ if self.is_dark_mode:
+ self._apply_dark_theme_style()
+ else:
+ self._apply_light_theme_style()
+
+ def _apply_light_theme_style(self):
+ """Apply light theme styles"""
+ self.setStyleSheet("""
+ QFrame {
+ border: 2px dashed #aaa;
+ border-radius: 10px;
+ background-color: #f9f9f9;
+ }
+ QFrame:hover {
+ border-color: #007acc;
+ background-color: #f0f8ff;
+ }
+ QFrame:drop {
+ border-color: #28a745;
+ background-color: #f0fff0;
+ }
+ """)
+ self.text_label.setStyleSheet("color: #666; font-size: 12px;")
+
+ def _apply_dark_theme_style(self):
+ """Apply dark theme styles"""
+ self.setStyleSheet("""
+ QFrame {
+ border: 2px dashed #555;
+ border-radius: 10px;
+ background-color: #2d2d2d;
+ }
+ QFrame:hover {
+ border-color: #007acc;
+ background-color: #1e3a5f;
+ }
+ QFrame:drop {
+ border-color: #28a745;
+ background-color: #1a2f1a;
+ }
+ """)
+ self.text_label.setStyleSheet("color: #aaa; font-size: 12px;")
+
+ def browse_files(self, event):
+ """Open file browser when clicked"""
+ file_dialog = QFileDialog()
+ file_paths, _ = file_dialog.getOpenFileNames(
+ self,
+ "Select Archive Files",
+ "",
+ "Archive Files (*.zip *.rar *.7z *.tar *.gz *.bz2 *.xz *.cab *.iso *.arj *.ace *.lzh *.lha);;All Files (*)"
+ )
+ if file_paths:
+ self.files_dropped.emit(file_paths)
+
+ def dragEnterEvent(self, event):
+ if event.mimeData().hasUrls():
+ # Check if any of the dragged items are supported archive formats
+ has_supported_files = False
+ total_files = 0
+ supported_files = 0
+
+ for url in event.mimeData().urls():
+ if hasattr(url, 'toLocalFile'):
+ path = url.toLocalFile()
+ else:
+ path = url.path() if hasattr(url, 'path') else ""
+
+ if path and os.path.isfile(path):
+ total_files += 1
+ if self._is_supported_archive_file(path):
+ supported_files += 1
+
+ # Accept if there are supported archive files
+ if supported_files > 0:
+ self._set_drag_over_style(True)
+ event.acceptProposedAction()
+ self._update_text_label(total_files, supported_files, True)
+ else:
+ self._set_reject_style()
+ else:
+ event.ignore()
+
+ def dragLeaveEvent(self, event):
+ self._reset_style()
+
+ def dragMoveEvent(self, event):
+ if event.mimeData().hasUrls():
+ self._set_drag_over_style(True)
+ event.acceptProposedAction()
+ else:
+ event.ignore()
+
+ def dropEvent(self, event):
+ self._reset_style()
+
+ files = []
+
+ for url in event.mimeData().urls():
+ path = url.toLocalFile()
+ if os.path.isfile(path) and self._is_supported_archive_file(path):
+ files.append(path)
+
+ # Emit signal for valid archive files
+ if files:
+ self.files_dropped.emit(files)
+
+ def _is_supported_archive_file(self, file_path):
+ """Check if the file has a supported archive format extension"""
+ if not file_path:
+ return False
+ _, ext = os.path.splitext(file_path.lower())
+ return ext in self.supported_formats
+
+ def _set_drag_over_style(self, has_supported=True):
+ """Set style for drag over state"""
+ # Ensure fixed size is maintained during style changes
+ current_width = self.width()
+ current_height = self.height()
+
+ if self.is_dark_mode:
+ self.setStyleSheet("""
+ QFrame {
+ border: 2px solid #28a745;
+ border-radius: 10px;
+ background-color: #1a2f1a;
+ }
+ """)
+ else:
+ self.setStyleSheet("""
+ QFrame {
+ border: 2px solid #28a745;
+ border-radius: 10px;
+ background-color: #f0fff0;
+ }
+ """)
+
+ # Restore fixed size after style change
+ self.setFixedHeight(100)
+ if current_width > 0:
+ self.setMinimumWidth(current_width)
+
+ self.drag_over = True
+
+ def _set_reject_style(self):
+ """Set style for rejected drag items"""
+ # Ensure fixed size is maintained during style changes
+ current_width = self.width()
+
+ if self.is_dark_mode:
+ self.setStyleSheet("""
+ QFrame {
+ border: 2px solid #dc3545;
+ border-radius: 10px;
+ background-color: #2a1a1a;
+ }
+ """)
+ else:
+ self.setStyleSheet("""
+ QFrame {
+ border: 2px solid #dc3545;
+ border-radius: 10px;
+ background-color: #fff5f5;
+ }
+ """)
+
+ # Restore fixed size after style change
+ self.setFixedHeight(100)
+ if current_width > 0:
+ self.setMinimumWidth(current_width)
+
+ def _reset_style(self):
+ """Reset to default style"""
+ # Ensure fixed size is maintained during style changes
+ current_width = self.width()
+
+ if self.is_dark_mode:
+ self._apply_dark_theme_style()
+ else:
+ self._apply_light_theme_style()
+
+ # Restore fixed size after style change
+ self.setFixedHeight(100)
+ if current_width > 0:
+ self.setMinimumWidth(current_width)
+
+ self.drag_over = False
+
+ def _update_text_label(self, total_files, supported_files, has_supported):
+ """Update the text label during drag operations"""
+ if has_supported:
+ if total_files == supported_files:
+ self.text_label.setText(f"Release to add {supported_files} archive file(s)")
+ else:
+ self.text_label.setText(f"Release to add {supported_files} archive file(s)\n({total_files - supported_files} unsupported file(s) will be ignored)")
diff --git a/support/GUI/image_support.py b/support/GUI/image_support.py
new file mode 100644
index 0000000..0dcef90
--- /dev/null
+++ b/support/GUI/image_support.py
@@ -0,0 +1,935 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import os
+from PySide6.QtWidgets import (
+ QFrame, QVBoxLayout, QLabel, QHBoxLayout, QWidget,
+ QFileDialog
+)
+from PySide6.QtCore import QRect
+from PySide6.QtGui import QPixmap, QFont, QPainter, QColor
+from PySide6.QtCore import Qt, QSize, Signal
+from qfluentwidgets import *
+
+
+class DropZoneWidget(QFrame):
+ """Custom widget for drag and drop file/folder selection with support for all image formats"""
+ filesDropped = Signal(list) # Signal for multiple files dropped
+ folderDropped = Signal(str) # Signal for folder dropped
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setAcceptDrops(True)
+ self.setMinimumHeight(100)
+ self.setFixedHeight(100) # 设置固定高度防止缩小
+ self.setMinimumWidth(200) # 设置最小宽度
+ self.is_dark_mode = False # Track current theme mode
+
+ # Define supported image formats
+ self.supported_formats = {
+ # Common formats
+ '.png', '.jpg', '.jpeg', '.bmp', '.gif', '.tiff', '.tif', '.ico', '.icns',
+ # Web formats
+ '.webp', '.svg',
+ # High efficiency formats
+ '.heic', '.heif', '.avif', '.jxl',
+ # Other formats
+ '.pdf', '.eps', '.dds', '.exr'
+ }
+
+ # 设置初始状态变量
+ self.is_dark_mode = False # 初始化为浅色主题
+
+ # 创建布局
+ layout = QVBoxLayout(self)
+ layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ layout.setContentsMargins(10, 10, 10, 10) # 设置内边距防止内容紧贴边框
+
+ self.icon_label = QLabel("📁")
+ self.icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.icon_label.setStyleSheet("font-size: 24px;")
+
+ self.text_label = QLabel("Drag files or folders here\n(Supports: PNG, JPG, JPEG, BMP, GIF, TIFF, ICO, ICNS, WebP, SVG, HEIC, HEIF, AVIF, JXL, PDF, EPS, DDS, EXR)")
+ self.text_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.text_label.setStyleSheet("color: #666; font-size: 12px;")
+ self.text_label.setWordWrap(True)
+
+ # 应用初始浅色主题样式(在组件创建之后)
+ self._apply_light_theme_style()
+
+ layout.addWidget(self.icon_label)
+ layout.addWidget(self.text_label)
+
+ self.drag_over = False
+
+ # Click to browse
+ self.mousePressEvent = self.browse_files
+
+ def sizeHint(self):
+ """Return fixed size hint to prevent resizing"""
+ return super().sizeHint()
+
+ def minimumSizeHint(self):
+ """Return minimum size hint to prevent shrinking"""
+ return QSize(200, 100)
+
+ def set_theme(self, is_dark_mode):
+ """Update the theme of the drag and drop area"""
+ self.is_dark_mode = is_dark_mode
+ if self.is_dark_mode:
+ self._apply_dark_theme_style()
+ else:
+ self._apply_light_theme_style()
+
+ def _apply_light_theme_style(self):
+ """Apply light theme styles"""
+ self.setStyleSheet("""
+ QFrame {
+ border: 2px dashed #aaa;
+ border-radius: 10px;
+ background-color: #f9f9f9;
+ }
+ QFrame:hover {
+ border-color: #007acc;
+ background-color: #f0f8ff;
+ }
+ QFrame:drop {
+ border-color: #28a745;
+ background-color: #f0fff0;
+ }
+ """)
+ self.text_label.setStyleSheet("color: #666; font-size: 12px;")
+
+ def _apply_dark_theme_style(self):
+ """Apply dark theme styles"""
+ self.setStyleSheet("""
+ QFrame {
+ border: 2px dashed #555;
+ border-radius: 10px;
+ background-color: #2d2d2d;
+ }
+ QFrame:hover {
+ border-color: #007acc;
+ background-color: #1e3a5f;
+ }
+ QFrame:drop {
+ border-color: #28a745;
+ background-color: #1a2f1a;
+ }
+ """)
+ self.text_label.setStyleSheet("color: #aaa; font-size: 12px;")
+
+ def browse_files(self, event):
+ """Open file browser when clicked"""
+ file_dialog = QFileDialog()
+ file_paths, _ = file_dialog.getOpenFileNames(
+ self,
+ "Select Image Files",
+ "",
+ "Image Files (*.png *.jpg *.jpeg *.bmp *.gif *.tiff *.ico *.webp);;All Files (*)"
+ )
+ if file_paths:
+ self.filesDropped.emit(file_paths)
+
+ def dragEnterEvent(self, event):
+ if event.mimeData().hasUrls():
+ # Check if any of the dragged items are supported formats
+ has_supported_files = False
+ total_files = 0
+ supported_files = 0
+ has_folders = False
+
+ for url in event.mimeData().urls():
+ if hasattr(url, 'toLocalFile'):
+ path = url.toLocalFile()
+ else:
+ path = url.path() if hasattr(url, 'path') else ""
+
+ if path and os.path.isfile(path):
+ total_files += 1
+ if self._is_supported_image_file(path):
+ supported_files += 1
+ elif path and os.path.isdir(path):
+ total_files += 1
+ has_folders = True # Folders count as one item
+
+ # Accept if there are supported image files or folders
+ if supported_files > 0 or has_folders:
+ self._set_drag_over_style(True)
+ event.acceptProposedAction()
+ self._update_text_label(total_files, supported_files, True)
+ else:
+ # Show rejection style
+ self._set_reject_style()
+ else:
+ event.ignore()
+
+ def dragLeaveEvent(self, event):
+ self._reset_style()
+
+ def dragMoveEvent(self, event):
+ if event.mimeData().hasUrls():
+ self._set_drag_over_style(True)
+ event.acceptProposedAction()
+ else:
+ event.ignore()
+
+ def dropEvent(self, event):
+ self._reset_style()
+
+ files = []
+ folders = []
+ rejected_files = []
+
+ for url in event.mimeData().urls():
+ path = url.toLocalFile()
+ if os.path.isdir(path):
+ folders.append(path)
+ elif os.path.isfile(path):
+ if self._is_supported_image_file(path):
+ files.append(path)
+ else:
+ rejected_files.append(path)
+
+ # Emit signals for valid items
+ if folders:
+ # For now, emit first folder (can be extended for multiple folders)
+ self.folderDropped.emit(folders[0])
+
+ if files:
+ self.filesDropped.emit(files)
+
+ # Show result message
+ if rejected_files:
+ self._show_rejected_files_message(rejected_files)
+
+ def _is_supported_image_file(self, file_path):
+ """Check if the file has a supported image format extension"""
+ if not file_path:
+ return False
+ _, ext = os.path.splitext(file_path.lower())
+ return ext in self.supported_formats
+
+ def _set_drag_over_style(self, has_supported=True):
+ """Set style for drag over state"""
+ # Ensure fixed size is maintained during style changes
+ current_width = self.width()
+
+ if has_supported:
+ if self.is_dark_mode:
+ self.setStyleSheet("""
+ QFrame {
+ border: 2px solid #28a745;
+ border-radius: 10px;
+ background-color: #1a2f1a;
+ }
+ """)
+ else:
+ self.setStyleSheet("""
+ QFrame {
+ border: 2px solid #28a745;
+ border-radius: 10px;
+ background-color: #f0fff0;
+ }
+ """)
+ self.drag_over = True
+ else:
+ if self.is_dark_mode:
+ self.setStyleSheet("""
+ QFrame {
+ border: 2px solid #007acc;
+ border-radius: 10px;
+ background-color: #1e3a5f;
+ }
+ """)
+ else:
+ self.setStyleSheet("""
+ QFrame {
+ border: 2px solid #007acc;
+ border-radius: 10px;
+ background-color: #e6f3ff;
+ }
+ """)
+
+ # Restore fixed size after style change
+ self.setFixedHeight(100)
+ if current_width > 0:
+ self.setMinimumWidth(current_width)
+
+ def _set_reject_style(self):
+ """Set style for rejected drag items"""
+ # Ensure fixed size is maintained during style changes
+ current_width = self.width()
+
+ if self.is_dark_mode:
+ self.setStyleSheet("""
+ QFrame {
+ border: 2px solid #dc3545;
+ border-radius: 10px;
+ background-color: #2a1a1a;
+ }
+ """)
+ else:
+ self.setStyleSheet("""
+ QFrame {
+ border: 2px solid #dc3545;
+ border-radius: 10px;
+ background-color: #fff5f5;
+ }
+ """)
+
+ # Restore fixed size after style change
+ self.setFixedHeight(100)
+ if current_width > 0:
+ self.setMinimumWidth(current_width)
+
+ def _reset_style(self):
+ """Reset to default style"""
+ # Ensure fixed size is maintained during style changes
+ current_width = self.width()
+
+ if self.is_dark_mode:
+ self._apply_dark_theme_style()
+ else:
+ self._apply_light_theme_style()
+
+ # Restore fixed size after style change
+ self.setFixedHeight(100)
+ if current_width > 0:
+ self.setMinimumWidth(current_width)
+
+ self.drag_over = False
+
+ def _update_text_label(self, total_files, supported_files, has_supported):
+ """Update the text label during drag operations"""
+ if has_supported:
+ if supported_files > 0:
+ if total_files == supported_files:
+ self.text_label.setText(f"Release to add {supported_files} image file(s)\n(Supported formats: {len(self.supported_formats)} types)")
+ else:
+ self.text_label.setText(f"Release to add {supported_files} image file(s)\n({total_files - supported_files} unsupported file(s) will be ignored)")
+ else:
+ # Check if we're dealing with folders
+ # If total_files is 1 and supported_files is 0, it's likely a folder
+ if total_files == 1:
+ self.text_label.setText("Release to process folder\n(Folder will be scanned for image files)")
+ else:
+ self.text_label.setText("Release to process folders\n(Folders will be scanned for image files)")
+ else:
+ self.text_label.setText("Drag image files or folders here")
+
+ def _show_rejected_files_message(self, rejected_files):
+ """Show message about rejected files"""
+ if rejected_files:
+ rejected_names = [os.path.basename(f) for f in rejected_files[:3]] # Show first 3
+ if len(rejected_files) > 3:
+ rejected_names.append(f"and {len(rejected_files) - 3} more...")
+
+ # You can add a tooltip or status message here
+ print(f"Rejected {len(rejected_files)} unsupported file(s): {', '.join(rejected_names)}")
+
+
+class DirectoryDropLineEdit(LineEdit):
+ """支持文件夹拖拽的输出路径输入框"""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setPlaceholderText("Drag folder here or click Browse...")
+
+ def dragEnterEvent(self, event):
+ """处理拖拽进入事件"""
+ if event.mimeData().hasUrls():
+ urls = event.mimeData().urls()
+ # 检查是否包含文件夹
+ has_folder = False
+ for url in urls:
+ if hasattr(url, 'toLocalFile'):
+ file_path = url.toLocalFile()
+ if file_path and os.path.isdir(file_path):
+ has_folder = True
+ break
+ elif hasattr(url, 'path') and os.path.isdir(url.path()):
+ has_folder = True
+ break
+
+ if has_folder:
+ event.acceptProposedAction()
+ self.setStyleSheet("""
+ LineEdit {
+ border: 2px solid #28a745;
+ border-radius: 4px;
+ background-color: #f0fff0;
+ }
+ """)
+ return
+ event.ignore()
+
+ def dragLeaveEvent(self, event):
+ """处理拖拽离开事件"""
+ self.setStyleSheet("")
+
+ def dragMoveEvent(self, event):
+ """处理拖拽移动事件"""
+ if event.mimeData().hasUrls():
+ urls = event.mimeData().urls()
+ # 检查是否包含文件夹
+ has_folder = False
+ for url in urls:
+ if hasattr(url, 'toLocalFile'):
+ file_path = url.toLocalFile()
+ if file_path and os.path.isdir(file_path):
+ has_folder = True
+ break
+ elif hasattr(url, 'path') and os.path.isdir(url.path()):
+ has_folder = True
+ break
+
+ if has_folder:
+ event.acceptProposedAction()
+ return
+ event.ignore()
+
+ def dropEvent(self, event):
+ """处理放置事件"""
+ self.setStyleSheet("")
+
+ if event.mimeData().hasUrls():
+ urls = event.mimeData().urls()
+ for url in urls:
+ if hasattr(url, 'toLocalFile'):
+ file_path = url.toLocalFile()
+ if file_path and os.path.isdir(file_path):
+ self.setText(file_path)
+ # 发射信号表示路径已设置
+ self.editingFinished.emit()
+ event.acceptProposedAction()
+ return
+ elif hasattr(url, 'path') and os.path.isdir(url.path()):
+ file_path = url.path()
+ self.setText(file_path)
+ self.editingFinished.emit()
+ event.acceptProposedAction()
+ return
+ event.ignore()
+
+
+class PreviewTab(QWidget):
+ """独立的预览标签页,支持单张和多张图片预览"""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.current_previews = [] # 当前显示的预览图片
+ self.setup_ui()
+
+ def setup_ui(self):
+ """设置预览标签页界面"""
+ layout = QVBoxLayout(self)
+
+ # 标题
+ title_label = QLabel("Preview")
+ title_font = QFont()
+ title_font.setPointSize(title_label.font().pointSize() + 4)
+ title_font.setBold(True)
+ title_label.setFont(title_font)
+ title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ layout.addWidget(title_label, 0, Qt.AlignmentFlag.AlignHCenter)
+
+ # 信息标签
+ self.info_label = QLabel("Drag image files to the tab above for preview")
+ self.info_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.info_label.setStyleSheet("color: #666; font-style: italic;")
+ layout.addWidget(self.info_label, 0, Qt.AlignmentFlag.AlignHCenter)
+
+ # 滚动区域用于显示多个预览
+ scroll_area = ScrollArea()
+ scroll_area.setWidgetResizable(True)
+ scroll_area.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+ scroll_area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAsNeeded)
+ scroll_area.setMinimumHeight(400)
+
+ # 预览容器
+ self.preview_container = QWidget()
+ self.preview_layout = QVBoxLayout(self.preview_container)
+ self.preview_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self.preview_layout.setSpacing(20)
+
+ scroll_area.setWidget(self.preview_container)
+ layout.addWidget(scroll_area)
+
+
+
+
+
+
+ def show_single_preview(self, file_path):
+ """显示单张图片预览"""
+ self.clear_previews()
+ self._create_single_preview_widget(file_path)
+ self._update_info_label(1, [file_path])
+
+ def show_multiple_previews(self, file_paths):
+ """显示多张图片预览"""
+ self.clear_previews()
+
+ # Filter out folders and only process files
+ valid_files = []
+ for file_path in file_paths:
+ if os.path.isfile(file_path):
+ valid_files.append(file_path)
+ self._create_single_preview_widget(file_path)
+ else:
+ # Skip folders, they will be handled differently
+ print(f"Skipping folder: {file_path}")
+
+ self._update_info_label(len(valid_files), valid_files)
+
+ def clear_previews(self):
+ """清除所有预览"""
+ # 清除现有的预览widget
+ for i in reversed(range(self.preview_layout.count())):
+ child = self.preview_layout.itemAt(i).widget()
+ if child:
+ child.setParent(None)
+ child.deleteLater()
+
+ self.current_previews = []
+
+ def _create_single_preview_widget(self, file_path):
+ """为单张图片创建预览widget"""
+ if not os.path.exists(file_path):
+ return
+
+ # Skip folders, they are not image files
+ if os.path.isdir(file_path):
+ print(f"Skipping folder preview: {file_path}")
+ return
+
+ # 创建单个预览的容器
+ preview_widget = QWidget()
+ preview_widget.setFixedSize(300, 320) # 固定大小
+ preview_widget.setStyleSheet("""
+ QWidget {
+ border: 1px solid #ddd;
+ border-radius: 8px;
+ background-color: white;
+ }
+ QWidget:hover {
+ border-color: #007acc;
+ background-color: #f8f9fa;
+ }
+ """)
+
+ preview_layout = QVBoxLayout(preview_widget)
+ preview_layout.setContentsMargins(10, 10, 10, 10)
+ preview_layout.setSpacing(10)
+
+ # 图片显示区域
+ image_label = QLabel()
+ image_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ image_label.setFixedSize(280, 220)
+ image_label.setStyleSheet("border: 1px solid #eee; border-radius: 4px; background-color: #f5f5f5;")
+
+ # 加载并显示图片
+ if self._can_load_image(file_path):
+ pixmap = None
+ try:
+ pixmap = QPixmap(file_path)
+ if not pixmap.isNull():
+ # 缩放图片以适应显示区域
+ scaled_pixmap = pixmap.scaled(
+ 270, 210,
+ Qt.AspectRatioMode.KeepAspectRatio,
+ Qt.TransformationMode.SmoothTransformation
+ )
+ image_label.setPixmap(scaled_pixmap)
+ else:
+ self._set_thumbnail_placeholder(image_label, file_path)
+ except Exception as e:
+ print(f"Error loading preview for {file_path}: {e}")
+ self._set_thumbnail_placeholder(image_label, file_path)
+ else:
+ self._set_thumbnail_placeholder(image_label, file_path)
+
+ preview_layout.addWidget(image_label, 0, Qt.AlignmentFlag.AlignCenter)
+
+ # 文件信息
+ info_widget = QWidget()
+ info_layout = QVBoxLayout(info_widget)
+ info_layout.setContentsMargins(0, 0, 0, 0)
+ info_layout.setSpacing(2)
+
+ # 文件名
+ filename_label = QLabel(os.path.basename(file_path))
+ filename_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ filename_label.setStyleSheet("font-weight: bold; color: #333;")
+ filename_label.setWordWrap(True)
+
+ # 文件大小
+ try:
+ file_size = os.path.getsize(file_path)
+ size_text = self._format_file_size(file_size)
+ except:
+ size_text = "Unknown size"
+
+ size_label = QLabel(size_text)
+ size_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ size_label.setStyleSheet("color: #666; font-size: 11px;")
+
+ info_layout.addWidget(filename_label)
+ info_layout.addWidget(size_label)
+ preview_layout.addWidget(info_widget)
+
+ # 添加到容器
+ self.preview_layout.addWidget(preview_widget, 0, Qt.AlignmentFlag.AlignCenter)
+ self.current_previews.append((file_path, preview_widget))
+
+ def _can_load_image(self, file_path):
+ """检查是否能直接加载图片"""
+ if not file_path:
+ return False
+
+ _, ext = os.path.splitext(file_path.lower())
+ direct_load_formats = {'.png', '.jpg', '.jpeg', '.bmp', '.gif', '.tiff', '.ico', '.icns', '.webp'}
+
+ return ext in direct_load_formats
+
+ def _set_thumbnail_placeholder(self, label, file_path):
+ """设置缩略图占位符"""
+ _, ext = os.path.splitext(file_path.lower())
+
+ # 格式图标映射
+ format_icons = {
+ '.svg': '🖼️',
+ '.pdf': '📄',
+ '.eps': '📄',
+ '.heic': '📱',
+ '.heif': '📱',
+ '.avif': '🖼️',
+ '.jxl': '🖼️',
+ '.dds': '🎮',
+ '.exr': '🎬'
+ }
+
+ icon = format_icons.get(ext, '📷')
+
+ # 创建占位符
+ placeholder_pixmap = QPixmap(270, 210)
+ placeholder_pixmap.fill(QColor('#f5f5f5'))
+
+ painter = QPainter(placeholder_pixmap)
+ painter.setPen(QColor('#999'))
+ painter.setFont(QFont('Arial', 48))
+
+ # 绘制图标
+ painter.drawText(placeholder_pixmap.rect(), Qt.AlignmentFlag.AlignCenter, icon)
+
+ # 绘制格式信息
+ painter.setFont(QFont('Arial', 12))
+ painter.drawText(
+ QRect(0, 170, 270, 40),
+ Qt.AlignmentFlag.AlignCenter,
+ f"{ext.upper()[1:]} Format"
+ )
+
+ painter.end()
+
+ label.setPixmap(placeholder_pixmap)
+
+ # 设置tooltip
+ label.setToolTip(f"Preview not available - {ext.upper()[1:]} Format\nWill show converted result")
+
+ def _format_file_size(self, size_bytes):
+ """格式化文件大小"""
+ if size_bytes == 0:
+ return "0 B"
+
+ size_names = ["B", "KB", "MB", "GB"]
+ import math
+ i = int(math.floor(math.log(size_bytes, 1024)))
+ p = math.pow(1024, i)
+ s = round(size_bytes / p, 2)
+ return f"{s} {size_names[i]}"
+
+ def _update_info_label(self, count, file_paths):
+ """更新信息标签"""
+ if count == 0:
+ self.info_label.setText("Drag image files to the tab above for preview")
+ self.info_label.setStyleSheet("color: #666; font-style: italic;")
+ elif count == 1:
+ filename = os.path.basename(file_paths[0])
+ self.info_label.setText(f"Previewing: {filename}")
+ self.info_label.setStyleSheet("color: #007acc; font-weight: bold;")
+ else:
+ self.info_label.setText(f"Previewing {count} images")
+ self.info_label.setStyleSheet("color: #28a745; font-weight: bold;")
+
+
+class ThumbnailGridWidget(QWidget):
+ """Custom widget for displaying thumbnail grid of batch files"""
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setMinimumHeight(150)
+ self.thumbnails = []
+ self.setup_ui()
+
+ def setup_ui(self):
+ layout = QVBoxLayout(self)
+
+ self.scroll_area = ScrollArea()
+ self.scroll_area.setWidgetResizable(True)
+ self.scroll_area.setMaximumHeight(200)
+
+ self.thumbnail_container = QWidget()
+ self.thumbnail_layout = QVBoxLayout(self.thumbnail_container)
+
+ self.scroll_area.setWidget(self.thumbnail_container)
+ layout.addWidget(self.scroll_area)
+
+ # Placeholder text
+ placeholder_label = QLabel("No files selected")
+ placeholder_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ placeholder_label.setStyleSheet("color: #999; font-style: italic;")
+ self.thumbnail_layout.addWidget(placeholder_label)
+
+ def add_thumbnails(self, file_paths):
+ """Add thumbnails for the given file paths"""
+ # Clear existing thumbnails
+ self.clear_thumbnails()
+
+ # Define supported image formats
+ supported_formats = {'.png', '.jpg', '.jpeg', '.webp', '.ico', '.bmp', '.tiff', '.tif', '.gif', '.svg', '.psd'}
+
+ for file_path in file_paths:
+ try:
+ if os.path.exists(file_path):
+ _, ext = os.path.splitext(file_path.lower())
+
+ if ext in supported_formats:
+ thumbnail = self.create_thumbnail_widget(file_path, ext)
+ self.thumbnail_layout.addWidget(thumbnail)
+ self.thumbnails.append(thumbnail)
+ else:
+ # Create placeholder for unsupported formats
+ thumbnail = self.create_unsupported_thumbnail(file_path, ext)
+ self.thumbnail_layout.addWidget(thumbnail)
+ self.thumbnails.append(thumbnail)
+ except Exception as e:
+ print(f"Error creating thumbnail for {file_path}: {e}")
+
+ # Remove placeholder if thumbnails were added
+ if self.thumbnails:
+ for i in range(self.thumbnail_layout.count()):
+ item = self.thumbnail_layout.itemAt(i)
+ if item and item.widget() and isinstance(item.widget(), QLabel):
+ if item.widget().text() == "No files selected":
+ widget = item.widget()
+ self.thumbnail_layout.removeWidget(widget)
+ widget.deleteLater()
+ break
+
+ def create_thumbnail_widget(self, file_path, file_ext):
+ """Create a thumbnail widget for a single file"""
+ container = QWidget()
+ container.setMaximumHeight(80)
+ container.setStyleSheet("""
+ QWidget {
+ border: 1px solid #ddd;
+ border-radius: 5px;
+ margin: 2px;
+ padding: 5px;
+ }
+ QWidget:hover {
+ border-color: #007acc;
+ background-color: #f5f5f5;
+ }
+ """)
+
+ layout = QHBoxLayout(container)
+ layout.setContentsMargins(5, 5, 5, 5)
+
+ # Thumbnail image or icon
+ thumbnail_label = QLabel()
+ thumbnail_label.setFixedSize(60, 60)
+ thumbnail_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+
+ # Try to load image for supported formats
+ if self._can_load_image(file_ext):
+ pixmap = None
+ try:
+ pixmap = QPixmap(file_path)
+ if not pixmap.isNull():
+ scaled_pixmap = pixmap.scaled(60, 60, Qt.AspectRatioMode.KeepAspectRatio,
+ Qt.TransformationMode.SmoothTransformation)
+ thumbnail_label.setPixmap(scaled_pixmap)
+ else:
+ self._set_thumbnail_placeholder(thumbnail_label, file_ext, "Load Error")
+ except Exception as e:
+ print(f"Error loading image {file_path}: {e}")
+ self._set_thumbnail_placeholder(thumbnail_label, file_ext, "Error")
+ else:
+ self._set_thumbnail_placeholder(thumbnail_label, file_ext)
+
+ # File info
+ info_layout = QVBoxLayout()
+ filename_label = QLabel(os.path.basename(file_path))
+ filename_label.setStyleSheet("font-weight: bold; font-size: 12px;")
+
+ # Get file size
+ try:
+ size_bytes = os.path.getsize(file_path)
+ size_str = self.format_file_size(size_bytes)
+ except:
+ size_str = "Unknown size"
+
+ size_label = QLabel(f"Size: {size_str}")
+ size_label.setStyleSheet("color: #666; font-size: 10px;")
+
+ info_layout.addWidget(filename_label)
+ info_layout.addWidget(size_label)
+ info_layout.addStretch()
+
+ layout.addWidget(thumbnail_label)
+ layout.addLayout(info_layout)
+ layout.addStretch()
+
+ return container
+
+ def create_unsupported_thumbnail(self, file_path, file_ext):
+ """Create a thumbnail widget for unsupported file formats"""
+ container = QWidget()
+ container.setMaximumHeight(80)
+ container.setStyleSheet("""
+ QWidget {
+ border: 1px dashed #ccc;
+ border-radius: 5px;
+ margin: 2px;
+ padding: 5px;
+ background-color: #f9f9f9;
+ }
+ QWidget:hover {
+ border-color: #999;
+ background-color: #f0f0f0;
+ }
+ """)
+
+ layout = QHBoxLayout(container)
+ layout.setContentsMargins(5, 5, 5, 5)
+
+ # Icon for unsupported format
+ icon_label = QLabel()
+ icon_label.setFixedSize(60, 60)
+ icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ self._set_thumbnail_placeholder(icon_label, file_ext, show_format=True)
+
+ # File info
+ info_layout = QVBoxLayout()
+ filename_label = QLabel(os.path.basename(file_path))
+ filename_label.setStyleSheet("font-weight: bold; font-size: 12px; color: #666;")
+
+ # Get file size
+ try:
+ size_bytes = os.path.getsize(file_path)
+ size_str = self.format_file_size(size_bytes)
+ except:
+ size_str = "Unknown size"
+
+ size_label = QLabel(f"Size: {size_str}")
+ size_label.setStyleSheet("color: #999; font-size: 10px;")
+
+ info_layout.addWidget(filename_label)
+ info_layout.addWidget(size_label)
+ info_layout.addStretch()
+
+ layout.addWidget(icon_label)
+ layout.addLayout(info_layout)
+ layout.addStretch()
+
+ return container
+
+ def _can_load_image(self, file_ext):
+ """Check if the file extension can be loaded as an image by Qt"""
+ # Qt supports most common formats, but some require plugins
+ loadable_formats = {'.png', '.jpg', '.jpeg', '.webp', '.ico', '.bmp', '.tif', '.tiff', '.gif'}
+ return file_ext.lower() in loadable_formats
+
+ def _set_thumbnail_placeholder(self, label, file_ext, error_text=None, show_format=False):
+ """Set placeholder text and style for thumbnail label"""
+ if error_text:
+ # Error state
+ label.setText(error_text)
+ label.setStyleSheet("""
+ QLabel {
+ border: 1px solid #ddd;
+ border-radius: 5px;
+ background-color: #ffe6e6;
+ color: #dc3545;
+ font-size: 10px;
+ font-weight: bold;
+ }
+ """)
+ elif show_format:
+ # Show format information for unsupported but recognized formats
+ format_name = file_ext.upper()[1:] if file_ext.startswith('.') else file_ext.upper()
+ icon_map = {
+ 'SVG': '🎨',
+ 'PSD': '🎨',
+ 'BMP': '🖼️',
+ 'TIFF': '🖼️',
+ 'TIF': '🖼️',
+ 'GIF': '🖼️',
+ 'PNG': '🖼️',
+ 'JPG': '🖼️',
+ 'JPEG': '🖼️',
+ 'WEBP': '🖼️',
+ 'ICO': '🖼️'
+ }
+ icon = icon_map.get(format_name, '📄')
+ label.setText(f"{icon}\n{format_name}")
+ label.setStyleSheet("""
+ QLabel {
+ border: 1px solid #ddd;
+ border-radius: 5px;
+ background-color: #f5f5f5;
+ color: #666;
+ font-size: 8px;
+ font-weight: bold;
+ text-align: center;
+ }
+ """)
+ else:
+ # Generic file icon
+ label.setText("📄")
+ label.setStyleSheet("""
+ QLabel {
+ border: 1px solid #ddd;
+ border-radius: 5px;
+ background-color: #f5f5f5;
+ color: #666;
+ font-size: 24px;
+ }
+ """)
+
+ def clear_thumbnails(self):
+ """Clear all thumbnails"""
+ # Remove all thumbnail widgets
+ for thumbnail in self.thumbnails:
+ if thumbnail and thumbnail.parent():
+ thumbnail.setParent(None)
+ thumbnail.deleteLater()
+
+ self.thumbnails.clear()
+
+ # Add placeholder back
+ placeholder_label = QLabel("No files selected")
+ placeholder_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ placeholder_label.setStyleSheet("color: #999; font-style: italic;")
+ self.thumbnail_layout.addWidget(placeholder_label)
+
+ def format_file_size(self, size_bytes):
+ """Format file size in human readable format"""
+ if size_bytes < 1024:
+ return f"{size_bytes} B"
+ elif size_bytes < 1024 * 1024:
+ return f"{size_bytes / 1024:.1f} KB"
+ else:
+ return f"{size_bytes / (1024 * 1024):.1f} MB"
diff --git a/support/__pycache__/convert.cpython-314.pyc b/support/__pycache__/convert.cpython-314.pyc
index a5343ad..ed845e0 100644
Binary files a/support/__pycache__/convert.cpython-314.pyc and b/support/__pycache__/convert.cpython-314.pyc differ
diff --git a/support/archive_manager.py b/support/archive_manager.py
index f284b3a..f7842df 100644
--- a/support/archive_manager.py
+++ b/support/archive_manager.py
@@ -122,12 +122,12 @@ def _get_archive_type(file_path):
return "lzh"
return None
-def _run_command_with_timeout(cmd, timeout=2, progress_callback=None):
+def _run_command_with_timeout(cmd, timeout=2, progress_callback=None, cwd=None):
"""Run command with timeout limit"""
start_time = time.time()
try:
- result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, cwd=cwd)
if progress_callback:
elapsed = time.time() - start_time
@@ -355,13 +355,13 @@ def create_archive(output_path, source_paths, archive_format, progress_callback=
if progress_callback:
progress_callback(f"Starting {archive_format} archive creation...", 0)
- # For password-protected ZIP files, use direct processing without wrapper directory
+ # For password-protected ZIP files, use CLI tool
if archive_format == "zip" and password:
if progress_callback:
- progress_callback(f"Creating password-protected ZIP file directly...", 10)
+ progress_callback(f"Creating password-protected ZIP file with CLI tool...", 10)
- # Use a specialized function for password-protected ZIP files
- success = _create_password_protected_zip(output_path, source_paths, password, progress_callback)
+ # Use CLI tool for password-protected ZIP files
+ success = _create_zip_with_cli(output_path, source_paths, progress_callback, password)
if not success:
raise RuntimeError(f"Failed to create password-protected ZIP archive")
else:
@@ -370,10 +370,14 @@ def create_archive(output_path, source_paths, archive_format, progress_callback=
temp_dir = tempfile.mkdtemp()
wrapper_dir = os.path.join(temp_dir, archive_name)
- # Ensure the wrapper directory doesn't already exist
- if os.path.exists(wrapper_dir):
- shutil.rmtree(wrapper_dir)
- os.makedirs(wrapper_dir)
+ # Create the wrapper directory - ensure it doesn't already exist
+ try:
+ os.makedirs(wrapper_dir, exist_ok=False)
+ except FileExistsError:
+ # If directory already exists, remove it and create a new one
+ if os.path.exists(wrapper_dir):
+ shutil.rmtree(wrapper_dir)
+ os.makedirs(wrapper_dir)
try:
# Copy all source files to the wrapper directory
@@ -405,9 +409,12 @@ def create_archive(output_path, source_paths, archive_format, progress_callback=
progress_callback(f"Creating {archive_format} archive...", 40)
success = False
- if archive_format in ["zip", "tar", "tar.gz", "tar.bz2", "tar.xz", "zipx"]:
- # Use patool for processing
- success = _create_with_patool(output_path, [wrapped_source_path], archive_format, progress_callback, password)
+ if archive_format == "zip":
+ # Use CLI tool for ZIP creation
+ success = _create_zip_with_cli(output_path, [wrapped_source_path], progress_callback, password)
+ elif archive_format in ["tar", "tar.gz", "tar.bz2", "tar.xz", "zipx"]:
+ # Use CLI tool for processing
+ success = _create_tar_with_cli(output_path, [wrapped_source_path], archive_format, progress_callback)
elif archive_format in ["bz2", "xz", "lzma", "gz"]:
# These formats can only compress single files, so need to create tar first, then compress
success = _create_single_file_compression(output_path, [wrapped_source_path], archive_format, progress_callback)
@@ -445,279 +452,213 @@ def create_archive(output_path, source_paths, archive_format, progress_callback=
progress_callback(f"Error creating archive: {str(e)}", -1)
return False
-def _create_with_patool(output_path, source_paths, archive_format, progress_callback=None, password=None):
- """Create archive file using patool"""
- import subprocess
- try:
- import patoolib
- except ImportError:
- raise ImportError("patool is required for this format. Install with: pip install patool")
-
- # Create temporary directory
- temp_dir = tempfile.mkdtemp()
+
+
+def _create_single_file_compression(output_path, source_paths, compression_format, progress_callback=None):
+ """
+ Create single file compression format (bz2, xz, lzma, gz)
+ These formats can only compress single files, so need to create tar first, then compress
+ """
try:
- # Copy all source files to temporary directory
- for source_path in source_paths:
- if os.path.isfile(source_path):
- shutil.copy2(source_path, temp_dir)
- elif os.path.isdir(source_path):
- dest_dir = os.path.join(temp_dir, os.path.basename(source_path))
- # Ensure the destination directory doesn't already exist
- if os.path.exists(dest_dir):
- shutil.rmtree(dest_dir)
- shutil.copytree(source_path, dest_dir)
-
- # Use patool to create archive - fix path issues, use full paths
- temp_files = []
- for item in os.listdir(temp_dir):
- item_path = os.path.join(temp_dir, item)
- temp_files.append(item_path)
-
- if not temp_files:
- raise ValueError("No files found to archive")
+ # Create temporary directory
+ temp_dir = tempfile.mkdtemp()
+ try:
+ # Step 1: Create tar file using tar command
+ base_name = os.path.splitext(os.path.basename(output_path))[0]
+ tar_path = os.path.join(temp_dir, f"{base_name}.tar")
- if progress_callback:
- progress_callback(f"Creating {archive_format} archive with patool...", 50)
-
- # Add debug information
- if progress_callback:
- progress_callback(f"Output path: {output_path}", 55)
- progress_callback(f"Temp files: {temp_files}", 60)
-
- # For ZIP format with password, use Python's zipfile module
- if archive_format == "zip" and password:
if progress_callback:
- progress_callback(f"Creating password-protected ZIP file...", 65)
-
- password_success = False
-
- # First try to use zip command if available (most compatible)
- zip_tool = _get_cli_tool("zip")
- if zip_tool:
- if progress_callback:
- progress_callback(f"Trying to create password-protected ZIP with zip command...", 70)
-
- # Create a temporary directory for zip to work with
- temp_zip_dir = tempfile.mkdtemp()
- try:
- # Ensure output file doesn't exist
- if os.path.exists(output_path):
- os.remove(output_path)
-
- # Copy files to temp directory
- for file_path in temp_files:
- if os.path.isfile(file_path):
- shutil.copy2(file_path, temp_zip_dir)
- elif os.path.isdir(file_path):
- dest_dir = os.path.join(temp_zip_dir, os.path.basename(file_path))
- shutil.copytree(file_path, dest_dir)
-
- # Create password-protected ZIP with zip command
- cmd = [zip_tool, "-r", "-P", password, output_path, "."]
- result = subprocess.run(cmd, cwd=temp_zip_dir, capture_output=True, text=True)
-
- # Check if the command succeeded (return code 0) and the output file exists
- if result.returncode == 0 and os.path.exists(output_path) and os.path.getsize(output_path) > 0:
- password_success = True
- if progress_callback:
- progress_callback(f"Password-protected ZIP created with zip command", 85)
- else:
- if progress_callback:
- progress_callback(f"Failed to create password-protected ZIP with zip command: return code {result.returncode}", 75)
- finally:
- shutil.rmtree(temp_zip_dir)
+ progress_callback(f"Creating intermediate tar file...", 30)
+
+ # Build tar command
+ cmd = ["tar", "-c", "-f", tar_path]
+
+ # Process source paths
+ if len(source_paths) == 1:
+ source_path = source_paths[0]
+ if os.path.isdir(source_path):
+ # For directories, add directory contents
+ work_dir = os.path.dirname(source_path) or "."
+ rel_path = os.path.basename(source_path)
+ cmd.append(rel_path)
+ else:
+ # For files, use directory containing the file
+ work_dir = os.path.dirname(source_path) or "."
+ rel_path = os.path.basename(source_path)
+ cmd.append(rel_path)
else:
- if progress_callback:
- progress_callback(f"zip command not available, trying pyzipper...", 75)
-
- # If zip command failed, try to use pyzipper
- if not password_success:
- try:
- import pyzipper
- if progress_callback:
- progress_callback(f"Creating password-protected ZIP with pyzipper...", 70)
-
- # Create a password-protected ZIP with pyzipper
- with pyzipper.AESZipFile(output_path, 'w', compression=pyzipper.ZIP_DEFLATED, encryption=pyzipper.WZ_AES) as zf:
- zf.setpassword(password.encode())
- for file_path in temp_files:
- if os.path.isfile(file_path):
- # 只使用文件名,不包含路径
- zf.write(file_path, os.path.basename(file_path))
- elif os.path.isdir(file_path):
- # 对于目录,递归添加所有文件,保留目录结构
- for root, dirs, files in os.walk(file_path):
- for file in files:
- file_full_path = os.path.join(root, file)
- # 计算相对于目录的路径,保留子目录结构
- arcname = os.path.relpath(file_full_path, file_path)
- zf.write(file_full_path, arcname)
-
- password_success = True
- if progress_callback:
- progress_callback(f"Password-protected ZIP created with pyzipper", 85)
-
- except ImportError:
- if progress_callback:
- progress_callback(f"pyzipper not available, trying 7zz...", 75)
- except Exception as e:
- if progress_callback:
- progress_callback(f"Failed to create password-protected ZIP with pyzipper: {str(e)}", 75)
+ # Multiple sources - use parent directory as working directory
+ work_dir = os.path.commonpath([os.path.dirname(p) if os.path.isfile(p) else p for p in source_paths])
+ for source_path in source_paths:
+ if os.path.isfile(source_path):
+ cmd.append(os.path.basename(source_path))
+ elif os.path.isdir(source_path):
+ cmd.append(os.path.basename(source_path))
- # If pyzipper failed, try to use 7zz
- if not password_success:
- sevenz_tool = _get_cli_tool("7zz")
- if sevenz_tool:
- if progress_callback:
- progress_callback(f"Trying to create password-protected ZIP with 7zz...", 75)
-
- # Create a temporary directory for 7zz to work with
- temp_7z_dir = tempfile.mkdtemp()
- try:
- # Copy files to temp directory
- for file_path in temp_files:
- if os.path.isfile(file_path):
- shutil.copy2(file_path, temp_7z_dir)
- elif os.path.isdir(file_path):
- dest_dir = os.path.join(temp_7z_dir, os.path.basename(file_path))
- shutil.copytree(file_path, dest_dir)
-
- # Create password-protected ZIP with 7zz
- cmd = [sevenz_tool, "a", f"-p{password}", "-y", output_path, os.path.join(temp_7z_dir, "*")]
- result = subprocess.run(cmd, capture_output=True, text=True)
-
- if result.returncode == 0:
- password_success = True
- if progress_callback:
- progress_callback(f"Password-protected ZIP created with 7zz", 85)
- else:
- if progress_callback:
- progress_callback(f"Failed to create password-protected ZIP with 7zz: {result.stderr}", 80)
- finally:
- shutil.rmtree(temp_7z_dir)
- else:
- if progress_callback:
- progress_callback(f"7zz not available", 75)
+ # Run tar command
+ result = _run_command_with_timeout(cmd, cwd=work_dir, timeout=30)
- # If pyzipper and 7zz both failed, we'll fall back to standard ZIP without password
- if not password_success:
- if progress_callback:
- progress_callback(f"All password protection methods failed", 75)
+ if result.returncode != 0:
+ raise RuntimeError(f"tar creation failed: {result.stderr}")
- # If all methods failed, create a non-password-protected ZIP
- if not password_success:
- if progress_callback:
- progress_callback(f"Warning: Could not create password-protected ZIP, creating standard ZIP", 75)
-
- with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_DEFLATED) as zf:
- for file_path in temp_files:
- if os.path.isfile(file_path):
- # 只使用文件名,不包含路径
- zf.write(file_path, os.path.basename(file_path))
- elif os.path.isdir(file_path):
- # 对于目录,递归添加所有文件,保留目录结构
- for root, dirs, files in os.walk(file_path):
- for file in files:
- file_full_path = os.path.join(root, file)
- # 计算相对于目录的路径,保留子目录结构
- arcname = os.path.relpath(file_full_path, file_path)
- zf.write(file_full_path, arcname)
-
- if progress_callback:
- progress_callback(f"Standard ZIP created (no password protection)", 85)
+ if not os.path.exists(tar_path):
+ raise RuntimeError("Failed to create intermediate tar file")
+ # Step 2: Compress tar file using appropriate command
if progress_callback:
- progress_callback(f"ZIP archive created", 90)
- else:
- # Try different patool calling methods
- try:
- # Method 1: Directly pass file paths
- patoolib.create_archive(output_path, temp_files)
- except Exception as e1:
- # Method 2: Switch to temporary directory and use relative paths
+ progress_callback(f"Compressing tar file with {compression_format}...", 60)
+
+ # Build compression command based on format
+ if compression_format == "gz":
try:
- original_cwd = os.getcwd()
- os.chdir(temp_dir)
- rel_files = [os.path.basename(f) for f in temp_files]
- patoolib.create_archive(output_path, rel_files)
- os.chdir(original_cwd)
- except Exception as e2:
- os.chdir(original_cwd)
-
- # Method 3: Use full paths and patool's --verbose option
+ gzip_tool = _get_cli_tool("gzip")
+ cmd = [gzip_tool, "-c", tar_path]
+ except FileNotFoundError:
+ # Fallback to system gzip
+ cmd = ["gzip", "-c", tar_path]
+ output_file = open(output_path, "wb")
+ result = subprocess.run(cmd, stdout=output_file, stderr=subprocess.PIPE)
+ output_file.close()
+ elif compression_format == "bz2":
+ try:
+ bzip2_tool = _get_cli_tool("bzip2")
+ cmd = [bzip2_tool, "-c", tar_path]
+ except FileNotFoundError:
+ # Fallback to system bzip2
+ cmd = ["bzip2", "-c", tar_path]
+ output_file = open(output_path, "wb")
+ result = subprocess.run(cmd, stdout=output_file, stderr=subprocess.PIPE)
+ output_file.close()
+ elif compression_format == "xz":
+ try:
+ xz_tool = _get_cli_tool("xz")
+ cmd = [xz_tool, "-c", tar_path]
+ output_file = open(output_path, "wb")
+ result = subprocess.run(cmd, stdout=output_file, stderr=subprocess.PIPE)
+ output_file.close()
+ except FileNotFoundError:
+ # Fallback to 7zz tool
try:
- import subprocess
- cmd = ["patool", "create", output_path] + temp_files
+ sevenz_tool = _get_cli_tool("7zz")
+ cmd = [sevenz_tool, "a", "-txz", output_path, tar_path]
result = subprocess.run(cmd, capture_output=True, text=True)
- if result.returncode != 0:
- raise RuntimeError(f"patool command failed: {result.stderr}")
- except Exception as e3:
- raise RuntimeError(f"All patool methods failed: {e1}, {e2}, {e3}")
+ except FileNotFoundError:
+ # Final fallback to system xz
+ cmd = ["xz", "-c", tar_path]
+ output_file = open(output_path, "wb")
+ result = subprocess.run(cmd, stdout=output_file, stderr=subprocess.PIPE)
+ output_file.close()
+ elif compression_format == "lzma":
+ # Use Python's lzma module directly
+ try:
+ import lzma
+ with open(tar_path, 'rb') as f_in:
+ with lzma.open(output_path, 'wb') as f_out:
+ shutil.copyfileobj(f_in, f_out)
+ # Create a dummy successful result
+ class DummyResult:
+ def __init__(self):
+ self.returncode = 0
+ self.stderr = ""
+ result = DummyResult()
+ except Exception as e:
+ raise RuntimeError(f"Failed to create LZMA archive: {str(e)}")
+ else:
+ raise ValueError(f"Unsupported compression format: {compression_format}")
- if progress_callback:
- progress_callback(f"{archive_format} archive created", 90)
+ if result.returncode != 0:
+ raise RuntimeError(f"Compression failed: {result.stderr}")
- return True
+ if progress_callback:
+ progress_callback(f"{compression_format} archive created", 90)
+
+ return True
+ finally:
+ shutil.rmtree(temp_dir)
except Exception as e:
if progress_callback:
- progress_callback(f"Error creating {archive_format} archive: {str(e)}", -1)
+ progress_callback(f"Error creating {compression_format} archive: {str(e)}", -1)
return False
- finally:
- shutil.rmtree(temp_dir)
-def _create_single_file_compression(output_path, source_paths, compression_format, progress_callback=None):
- """
- Create single file compression format (bz2, xz, lzma)
- These formats can only compress single files, so need to create tar first, then compress
- """
+def _create_zip_with_cli(output_path, source_paths, progress_callback=None, password=None):
+ """Create ZIP file using zip CLI tool"""
try:
- import patoolib
- except ImportError:
- raise ImportError("patool is required for this format. Install with: pip install patool")
+ zip_tool = _get_cli_tool("zip")
+ except FileNotFoundError:
+ # If zip tool is not found, raise an error instead of falling back to patool
+ raise RuntimeError("zip tool not found. Please install zip command-line tool")
+
+ # Build zip command - use working directory approach
+ temp_dir = None # Initialize temp_dir to None
+ if len(source_paths) == 1:
+ # Single source - can use directly
+ source_path = source_paths[0]
+ if os.path.isdir(source_path):
+ # For directories, use relative path from parent directory
+ work_dir = os.path.dirname(source_path) or "."
+ rel_path = os.path.basename(source_path)
+ else:
+ # For files, use directory containing the file
+ work_dir = os.path.dirname(source_path) or "."
+ rel_path = os.path.basename(source_path)
+ else:
+ # Multiple sources - create temporary directory approach
+ temp_dir = tempfile.mkdtemp()
+ work_dir = temp_dir
+ rel_paths = []
+
+ try:
+ for source_path in source_paths:
+ if os.path.isfile(source_path):
+ dest_path = os.path.join(temp_dir, os.path.basename(source_path))
+ shutil.copy2(source_path, dest_path)
+ rel_paths.append(os.path.basename(source_path))
+ elif os.path.isdir(source_path):
+ dest_path = os.path.join(temp_dir, os.path.basename(source_path))
+ shutil.copytree(source_path, dest_path)
+ rel_paths.append(os.path.basename(source_path))
+ except Exception:
+ if temp_dir and os.path.exists(temp_dir):
+ shutil.rmtree(temp_dir)
+ raise
+
+ source_paths = rel_paths
- # Create temporary directory
- temp_dir = tempfile.mkdtemp()
try:
- # Step 1: Create tar file
- base_name = os.path.splitext(os.path.basename(output_path))[0]
- tar_path = os.path.join(temp_dir, f"{base_name}.tar")
+ cmd = [zip_tool, "-r"]
+ if password:
+ cmd.extend(["-P", password])
+ cmd.append(output_path)
+ cmd.extend(source_paths if len(source_paths) > 1 else [rel_path])
+
+ # Ensure output path is absolute
+ output_path_abs = os.path.abspath(output_path)
+ cmd[cmd.index(output_path)] = output_path_abs
if progress_callback:
- progress_callback(f"Creating intermediate tar file...", 50)
+ progress_callback(f"Creating ZIP archive with CLI tool...", 50)
- # Create tar file
- patoolib.create_archive(tar_path, source_paths)
+ result = _run_command_with_timeout(cmd, cwd=work_dir, timeout=2, progress_callback=progress_callback)
- if not os.path.exists(tar_path):
- raise RuntimeError("Failed to create intermediate tar file")
+ if result.returncode != 0:
+ raise RuntimeError(f"ZIP creation failed: {result.stderr}")
- # Step 2: Compress tar file
if progress_callback:
- progress_callback(f"Compressing tar file with {compression_format}...", 70)
-
- # Use patool to compress tar file
- patoolib.create_archive(output_path, [tar_path])
+ progress_callback(f"ZIP archive created successfully", 90)
- if progress_callback:
- progress_callback(f"{compression_format} archive created", 90)
-
return True
- except Exception as e:
- if progress_callback:
- progress_callback(f"Error creating {compression_format} archive: {str(e)}", -1)
- return False
finally:
- shutil.rmtree(temp_dir)
+ # Clean up temporary directory if it was created
+ if temp_dir and os.path.exists(temp_dir):
+ shutil.rmtree(temp_dir)
def _create_cab_with_cli(output_path, source_paths, progress_callback=None):
"""Create CAB archive file using gcab tool"""
- try:
- gcab_tool = _get_cli_tool("gcab")
- except FileNotFoundError:
- # Try to get gcab from system path
- gcab_tool = shutil.which("gcab")
- if not gcab_tool:
- raise RuntimeError("gcab tool not found. Please install with: brew install gcab")
+ # Use project's built-in gcab tool directly
+ gcab_tool = os.path.join(CLI_BASE_PATH, "Universal", "gcab")
+
+ if not os.path.exists(gcab_tool):
+ raise RuntimeError(f"Built-in gcab tool not found at {gcab_tool}")
# Create temporary directory for source files
with tempfile.TemporaryDirectory() as temp_dir:
@@ -744,12 +685,12 @@ def _create_cab_with_cli(output_path, source_paths, progress_callback=None):
# Update progress
if progress_callback:
progress = (i + 1) / len(source_paths) * 50 # First half of progress for copying files
- progress_callback(progress)
+ progress_callback(f"Copying files for CAB archive...", progress)
if not copied_files:
raise ValueError("No valid source files found")
- # Build gcab command
+ # Build gcab command for CAB format
cmd = [gcab_tool, "-c", "-n", output_path]
cmd.extend([str(f) for f in copied_files])
@@ -765,7 +706,7 @@ def _create_cab_with_cli(output_path, source_paths, progress_callback=None):
# Update final progress
if progress_callback:
- progress_callback(100)
+ progress_callback("CAB archive created successfully", 100)
return True
except subprocess.CalledProcessError as e:
@@ -779,21 +720,140 @@ def _create_rar_with_cli(output_path, source_paths, progress_callback=None, pass
# Fallback: Use unar/lsar
return _create_with_unar(output_path, source_paths, "rar", progress_callback)
- # Build rar command
- cmd = [rar_tool, "a", "-r"]
- if password:
- cmd.extend(["-p" + password, "-y"])
+ # Build rar command - use working directory approach
+ temp_dir = None # Initialize temp_dir to None
+ if len(source_paths) == 1:
+ # Single source - can use directly
+ source_path = source_paths[0]
+ if os.path.isdir(source_path):
+ # For directories, use relative path from parent directory
+ work_dir = os.path.dirname(source_path) or "."
+ rel_path = os.path.basename(source_path)
+ else:
+ # For files, use directory containing the file
+ work_dir = os.path.dirname(source_path) or "."
+ rel_path = os.path.basename(source_path)
else:
- cmd.append("-y")
- cmd.append(output_path)
- cmd.extend(source_paths)
-
- result = _run_command_with_timeout(cmd, timeout=2, progress_callback=progress_callback)
+ # Multiple sources - create temporary directory approach
+ temp_dir = tempfile.mkdtemp()
+ work_dir = temp_dir
+ rel_paths = []
+
+ try:
+ for source_path in source_paths:
+ if os.path.isfile(source_path):
+ dest_path = os.path.join(temp_dir, os.path.basename(source_path))
+ shutil.copy2(source_path, dest_path)
+ rel_paths.append(os.path.basename(source_path))
+ elif os.path.isdir(source_path):
+ dest_path = os.path.join(temp_dir, os.path.basename(source_path))
+ shutil.copytree(source_path, dest_path)
+ rel_paths.append(os.path.basename(source_path))
+ except Exception:
+ if temp_dir and os.path.exists(temp_dir):
+ shutil.rmtree(temp_dir)
+ raise
+
+ source_paths = rel_paths
- if result.returncode != 0:
- raise RuntimeError(f"RAR creation failed: {result.stderr}")
+ try:
+ cmd = [rar_tool, "a", "-r"]
+ if password:
+ cmd.extend(["-p" + password, "-y"])
+ else:
+ cmd.append("-y")
+ cmd.append(output_path)
+ cmd.extend(source_paths if len(source_paths) > 1 else [rel_path])
+
+ # Ensure output path is absolute
+ output_path_abs = os.path.abspath(output_path)
+ cmd[cmd.index(output_path)] = output_path_abs
+
+ result = _run_command_with_timeout(cmd, cwd=work_dir, timeout=2, progress_callback=progress_callback)
+
+ if result.returncode != 0:
+ raise RuntimeError(f"RAR creation failed: {result.stderr}")
+
+ return True
+ finally:
+ # Clean up temporary directory if it was created
+ if temp_dir and os.path.exists(temp_dir):
+ shutil.rmtree(temp_dir)
+
+def _create_tar_with_cli(output_path, source_paths, archive_format, progress_callback=None, password=None):
+ """Create tar file using system tar command"""
+ try:
+ if progress_callback:
+ progress_callback(f"Creating {archive_format} archive with tar command...", 0)
+
+ # Ensure output path is absolute
+ output_path_abs = os.path.abspath(output_path)
+
+ # Build tar command based on format
+ cmd = ["tar", "-c"]
+
+ # Add compression options based on format
+ if archive_format == "tar.gz" or archive_format == "tgz":
+ cmd.append("-z")
+ elif archive_format == "tar.bz2" or archive_format == "tbz2":
+ cmd.append("-j")
+ elif archive_format == "tar.xz" or archive_format == "txz":
+ cmd.append("-J")
+
+ # Add output file option
+ cmd.extend(["-f", output_path_abs])
+
+ # Handle password protection for zipx format
+ if archive_format == "zipx" and password:
+ # For zipx, we'll create a standard zip with password protection
+ return _create_zip_with_cli(output_path, source_paths, progress_callback, password)
+
+ # Process source paths
+ if len(source_paths) == 1:
+ source_path = source_paths[0]
+ if os.path.isdir(source_path):
+ # For directories, add directory contents
+ work_dir = os.path.dirname(source_path) or "."
+ rel_path = os.path.basename(source_path)
+ cmd.append(rel_path)
+ else:
+ # For files, use directory containing the file
+ work_dir = os.path.dirname(source_path) or "."
+ rel_path = os.path.basename(source_path)
+ cmd.append(rel_path)
+ else:
+ # Multiple sources - use parent directory as working directory
+ work_dir = os.path.commonpath([os.path.dirname(p) if os.path.isfile(p) else p for p in source_paths])
+ for source_path in source_paths:
+ if os.path.isfile(source_path):
+ cmd.append(os.path.basename(source_path))
+ elif os.path.isdir(source_path):
+ cmd.append(os.path.basename(source_path))
+
+ if progress_callback:
+ progress_callback(f"Running tar command for {archive_format}...", 50)
+
+ # Run tar command
+ result = _run_command_with_timeout(cmd, cwd=work_dir, timeout=30, progress_callback=progress_callback)
+
+ if result.returncode != 0:
+ raise RuntimeError(f"tar creation failed: {result.stderr}")
+
+ # Handle zipx format (rename tar to zipx)
+ if archive_format == "zipx":
+ temp_path = output_path_abs + ".tmp"
+ os.rename(output_path_abs, temp_path)
+ os.rename(temp_path, output_path)
+
+ if progress_callback:
+ progress_callback(f"{archive_format} archive created successfully", 100)
+
+ return True
- return True
+ except Exception as e:
+ if progress_callback:
+ progress_callback(f"Error creating {archive_format} archive: {str(e)}", -1)
+ return False
def _create_7z_with_cli(output_path, source_paths, progress_callback=None, password=None):
"""Create 7z file using 7zz CLI tool"""
@@ -804,20 +864,65 @@ def _create_7z_with_cli(output_path, source_paths, progress_callback=None, passw
_create_with_unar(output_path, source_paths, "7z", progress_callback)
return True
- cmd = [sevenz_tool, "a"]
- if password:
- cmd.extend(["-p" + password, "-y"])
+ # Build 7z command - use working directory approach
+ temp_dir = None # Initialize temp_dir to None
+ if len(source_paths) == 1:
+ # Single source - can use directly
+ source_path = source_paths[0]
+ if os.path.isdir(source_path):
+ # For directories, use relative path from parent directory
+ work_dir = os.path.dirname(source_path) or "."
+ rel_path = os.path.basename(source_path)
+ else:
+ # For files, use directory containing the file
+ work_dir = os.path.dirname(source_path) or "."
+ rel_path = os.path.basename(source_path)
else:
- cmd.append("-y")
- cmd.append(output_path)
- cmd.extend(source_paths)
-
- result = _run_command_with_timeout(cmd, timeout=2, progress_callback=progress_callback)
-
- if result.returncode != 0:
- raise RuntimeError(f"7z creation failed: {result.stderr}")
+ # Multiple sources - create temporary directory approach
+ temp_dir = tempfile.mkdtemp()
+ work_dir = temp_dir
+ rel_paths = []
+
+ try:
+ for source_path in source_paths:
+ if os.path.isfile(source_path):
+ dest_path = os.path.join(temp_dir, os.path.basename(source_path))
+ shutil.copy2(source_path, dest_path)
+ rel_paths.append(os.path.basename(source_path))
+ elif os.path.isdir(source_path):
+ dest_path = os.path.join(temp_dir, os.path.basename(source_path))
+ shutil.copytree(source_path, dest_path)
+ rel_paths.append(os.path.basename(source_path))
+ except Exception:
+ if temp_dir and os.path.exists(temp_dir):
+ shutil.rmtree(temp_dir)
+ raise
+
+ source_paths = rel_paths
- return True
+ try:
+ cmd = [sevenz_tool, "a"]
+ if password:
+ cmd.extend(["-p" + password, "-y"])
+ else:
+ cmd.append("-y")
+ cmd.append(output_path)
+ cmd.extend(source_paths if len(source_paths) > 1 else [rel_path])
+
+ # Ensure output path is absolute
+ output_path_abs = os.path.abspath(output_path)
+ cmd[cmd.index(output_path)] = output_path_abs
+
+ result = _run_command_with_timeout(cmd, cwd=work_dir, timeout=2, progress_callback=progress_callback)
+
+ if result.returncode != 0:
+ raise RuntimeError(f"7z creation failed: {result.stderr}")
+
+ return True
+ finally:
+ # Clean up temporary directory if it was created
+ if temp_dir and os.path.exists(temp_dir):
+ shutil.rmtree(temp_dir)
def _create_iso_with_system(output_path, source_paths, progress_callback=None):
"""Create ISO file using system command"""
@@ -826,12 +931,15 @@ def _create_iso_with_system(output_path, source_paths, progress_callback=None):
source_dir = source_paths[0]
+ # Ensure output path is absolute
+ output_path_abs = os.path.abspath(output_path)
+
# Try using hdiutil (macOS)
if platform.system() == "Darwin":
- cmd = ["hdiutil", "makehybrid", "-o", output_path, "-hfs", "-iso", "-joliet", source_dir]
+ cmd = ["hdiutil", "makehybrid", "-o", output_path_abs, "-hfs", "-iso", "-joliet", source_dir]
else:
# Other systems use mkisofs
- cmd = ["mkisofs", "-o", output_path, "-J", "-R", source_dir]
+ cmd = ["mkisofs", "-o", output_path_abs, "-J", "-R", source_dir]
result = _run_command_with_timeout(cmd, timeout=60, progress_callback=progress_callback)
@@ -958,11 +1066,8 @@ def extract_archive(archive_path, extract_to, progress_callback=None, password=N
# Use unar as fallback on failure
_extract_with_unar(archive_path, extract_to, progress_callback, password)
elif archive_format in ["zipx"]:
- # Use patool, fallback to unar on failure
- try:
- _extract_with_patool(archive_path, extract_to, progress_callback, password)
- except Exception:
- _extract_with_unar(archive_path, extract_to, progress_callback, password)
+ # Use unar directly for zipx format
+ _extract_with_unar(archive_path, extract_to, progress_callback, password)
elif archive_format == "cab":
# Prioritize using cabextract tool to extract CAB files
try:
@@ -1019,6 +1124,17 @@ def extract_archive(archive_path, extract_to, progress_callback=None, password=N
if progress_callback:
progress_callback(f"Archive extracted to: {extract_to}", 100)
+
+ # 为解压出的可执行文件添加执行权限
+ try:
+ _set_executable_permissions(extract_to, progress_callback)
+ if progress_callback:
+ progress_callback("Executable permissions set", 100)
+ except Exception as e:
+ # 权限设置失败不应该影响解压结果,只是记录警告
+ if progress_callback:
+ progress_callback(f"Warning: Failed to set executable permissions: {str(e)}", 90)
+
return True
except Exception as e:
@@ -1045,6 +1161,18 @@ def _extract_with_python(archive_path, extract_to, archive_format, progress_call
total_files = len(file_list)
for i, file_name in enumerate(file_list):
try:
+ # 清理绝对路径,防止解压到系统目录
+ if file_name.startswith('/') or file_name.startswith('./'):
+ # 将绝对路径转换为相对路径
+ file_name = os.path.relpath(file_name, '/')
+ if file_name == '.':
+ continue # 跳过当前目录引用
+
+ # 防止路径遍历攻击(../)
+ if '..' in file_name.split(os.sep):
+ # 跳过包含父目录引用的路径
+ continue
+
zipf.extract(file_name, extract_to)
except (RuntimeError, pyzipper.BadZipFile) as e:
error_msg = str(e).lower()
@@ -1095,29 +1223,27 @@ def _extract_with_python(archive_path, extract_to, archive_format, progress_call
total_files = len(file_list)
for i, file_name in enumerate(file_list):
try:
- # 确保文件直接解压到目标目录,不保留路径结构
+ # 清理绝对路径,防止解压到系统目录
+ if file_name.startswith('/') or file_name.startswith('./'):
+ # 将绝对路径转换为相对路径
+ file_name = os.path.relpath(file_name, '/')
+ if file_name == '.':
+ continue # 跳过当前目录引用
+
+ # 防止路径遍历攻击(../)
+ if '..' in file_name.split(os.sep):
+ # 跳过包含父目录引用的路径
+ continue
+
+ # 保留压缩包的目录结构
if file_name.endswith('/'):
- # 如果是目录,创建目录
- dir_name = os.path.basename(file_name.rstrip('/'))
- if dir_name: # 只有当目录名不为空时才创建
- os.makedirs(os.path.join(extract_to, dir_name), exist_ok=True)
+ # 如果是目录,创建完整路径
+ dir_path = os.path.join(extract_to, file_name.rstrip('/'))
+ if dir_path and dir_path != extract_to: # 确保不是空路径或根目录
+ os.makedirs(dir_path, exist_ok=True)
else:
- # 如果是文件,直接解压到目标目录
+ # 如果是文件,直接解压,保留路径结构
zipf.extract(file_name, extract_to)
- # 如果文件在子目录中,移动到目标目录
- if '/' in file_name:
- base_name = os.path.basename(file_name)
- src_path = os.path.join(extract_to, file_name)
- dst_path = os.path.join(extract_to, base_name)
- if os.path.exists(src_path) and src_path != dst_path:
- shutil.move(src_path, dst_path)
- # 尝试删除空的子目录
- parent_dir = os.path.dirname(src_path)
- try:
- if os.path.exists(parent_dir) and parent_dir != extract_to:
- os.rmdir(parent_dir)
- except:
- pass # 如果目录不为空,忽略错误
except (RuntimeError, zipfile.BadZipFile) as e:
error_msg = str(e).lower()
# 检查更多可能的密码错误关键词
@@ -1132,6 +1258,20 @@ def _extract_with_python(archive_path, extract_to, archive_format, progress_call
if progress_callback:
progress = ((i + 1) / total_files) * 100
progress_callback(f"Extracting {file_name}", progress)
+
+ # 检查解压后的目录中是否包含系统目录
+ if progress_callback:
+ try:
+ # 检查解压后的目录中是否包含系统目录
+ for root, dirs, files in os.walk(extract_to):
+ for dir_name in dirs:
+ # 检查是否是系统目录
+ if dir_name.lower() in ['bin', 'sbin', 'usr', 'etc', 'var', 'sys', 'proc', 'dev', 'boot', 'lib', 'lib64', 'opt', 'run', 'srv', 'tmp']:
+ full_path = os.path.join(root, dir_name)
+ progress_callback(f"警告: 检测到系统目录 {full_path}", 50)
+ except Exception as e:
+ # 忽略检查过程中的错误
+ pass
elif archive_format.startswith("tar"):
mode = "r"
@@ -1146,61 +1286,89 @@ def _extract_with_python(archive_path, extract_to, archive_format, progress_call
members = tarf.getmembers()
total_members = len(members)
for i, member in enumerate(members):
- # Fix TAR.GZ extraction path issue - keep only filename, remove path
+ # 保留TAR文件的目录结构,但清理绝对路径
if member.name.startswith('/') or member.name.startswith('./'):
- member.name = os.path.basename(member.name)
- elif os.path.dirname(member.name): # If it contains a path
- member.name = os.path.basename(member.name)
+ # 将绝对路径转换为相对路径,但保留目录结构
+ member.name = os.path.relpath(member.name, '/')
+ if member.name == '.':
+ member.name = os.path.basename(member.name)
tarf.extract(member, extract_to)
if progress_callback:
progress = ((i + 1) / total_members) * 100
progress_callback(f"Extracting {member.name}", progress)
+
+ # 检查解压后的目录中是否包含系统目录
+ if progress_callback:
+ try:
+ # 检查解压后的目录中是否包含系统目录
+ for root, dirs, files in os.walk(extract_to):
+ for dir_name in dirs:
+ # 检查是否是系统目录
+ if dir_name.lower() in ['bin', 'sbin', 'usr', 'etc', 'var', 'sys', 'proc', 'dev', 'boot', 'lib', 'lib64', 'opt', 'run', 'srv', 'tmp']:
+ full_path = os.path.join(root, dir_name)
+ progress_callback(f"警告: 检测到系统目录 {full_path}", 50)
+ except Exception as e:
+ # 忽略检查过程中的错误
+ pass
elif archive_format in ["bz2", "xz", "lzma"]:
# Single file compression format
import bz2
import lzma
+ import tarfile
- output_file = os.path.join(extract_to, os.path.basename(archive_path).rsplit('.', 1)[0])
+ # First, decompress the file to a temporary tar file
+ temp_tar = os.path.join(extract_to, os.path.basename(archive_path).rsplit('.', 1)[0] + '.tar')
- if archive_format == "bz2":
- with bz2.open(archive_path, 'rb') as f_in:
- with open(output_file, 'wb') as f_out:
- # Use binary mode for reading and writing
- while True:
- chunk = f_in.read(8192)
- if not chunk:
- break
- f_out.write(chunk)
- else:
- with lzma.open(archive_path, 'rb') as f_in:
- with open(output_file, 'wb') as f_out:
- # Use binary mode for reading and writing
- while True:
- chunk = f_in.read(8192)
- if not chunk:
- break
- f_out.write(chunk)
+ try:
+ if archive_format == "bz2":
+ with bz2.open(archive_path, 'rb') as f_in:
+ with open(temp_tar, 'wb') as f_out:
+ # Use binary mode for reading and writing
+ while True:
+ chunk = f_in.read(8192)
+ if not chunk:
+ break
+ f_out.write(chunk)
+ else:
+ with lzma.open(archive_path, 'rb') as f_in:
+ with open(temp_tar, 'wb') as f_out:
+ # Use binary mode for reading and writing
+ while True:
+ chunk = f_in.read(8192)
+ if not chunk:
+ break
+ f_out.write(chunk)
+
+ # Now extract the tar file
+ with tarfile.open(temp_tar, 'r') as tarf:
+ members = tarf.getmembers()
+ total_members = len(members)
+ for i, member in enumerate(members):
+ # 保留TAR文件的目录结构,但清理绝对路径
+ if member.name.startswith('/') or member.name.startswith('./'):
+ # 将绝对路径转换为相对路径,但保留目录结构
+ member.name = os.path.relpath(member.name, '/')
+ if member.name == '.':
+ member.name = os.path.basename(member.name)
+ tarf.extract(member, extract_to)
+ if progress_callback:
+ progress = ((i + 1) / total_members) * 100
+ progress_callback(f"Extracting {member.name}", progress)
+
+ # Clean up the temporary tar file
+ os.remove(temp_tar)
+
+ except Exception as e:
+ # Clean up the temporary tar file if it exists
+ if os.path.exists(temp_tar):
+ os.remove(temp_tar)
+ raise e
if progress_callback:
- progress_callback(f"Extracted {output_file}", 100)
+ progress_callback(f"Extracted {archive_format} archive", 100)
+
-def _extract_with_patool(archive_path, extract_to, progress_callback=None, password=None):
- """Extract using patool"""
- try:
- import patoolib
- except ImportError:
- raise ImportError("patool is required for this format")
-
- # patoolib doesn't directly support password, so we'll use unar for password-protected archives
- if password:
- _extract_with_unar(archive_path, extract_to, progress_callback, password)
- return
-
- patoolib.extract_archive(archive_path, outdir=extract_to)
-
- if progress_callback:
- progress_callback("Archive extracted", 100)
def _extract_rar_with_cli(archive_path, extract_to, progress_callback=None, password=None):
"""Extract RAR file using unrar CLI tool"""
@@ -1215,7 +1383,7 @@ def _extract_rar_with_cli(archive_path, extract_to, progress_callback=None, pass
if password == "":
raise RuntimeError("Empty password not allowed for encrypted RAR file")
- cmd = [unrar_tool, "x", archive_path, extract_to + "/", "-ep"] # Add -ep parameter to ignore paths
+ cmd = [unrar_tool, "x", archive_path, extract_to + "/"] # 保留目录结构
if password:
cmd.extend(["-p" + password, "-y"]) # Add password and assume yes for all queries
else:
@@ -1261,6 +1429,17 @@ def _extract_rar_with_cli(archive_path, extract_to, progress_callback=None, pass
if not extracted_files:
raise RuntimeError("Password required for encrypted RAR file")
+
+ # 检查并警告潜在的绝对路径解压问题
+ if os.path.exists(extract_to):
+ # 检查解压目录中是否包含系统目录
+ for item in os.listdir(extract_to):
+ item_path = os.path.join(extract_to, item)
+ if os.path.isdir(item_path):
+ # 检查是否是常见的系统目录
+ if item in ['var', 'etc', 'usr', 'bin', 'sbin', 'lib', 'lib64', 'sys', 'proc', 'dev']:
+ if progress_callback:
+ progress_callback(f"Warning: System directory '{item}' was extracted. This may indicate absolute paths in the archive.", -1)
def _extract_7z_with_cli(archive_path, extract_to, progress_callback=None, password=None):
"""Extract 7z file using 7zz CLI tool"""
@@ -1319,27 +1498,16 @@ def _extract_7z_with_cli(archive_path, extract_to, progress_callback=None, passw
raise RuntimeError("Password required for encrypted 7Z file")
raise RuntimeError(f"7z extraction failed: {result.stderr}")
- # 检查是否有额外的子目录,如果有,将文件移动到根目录
+ # 检查并警告潜在的绝对路径解压问题
if os.path.exists(extract_to):
- items = os.listdir(extract_to)
- # 如果只有一个子目录,且该子目录包含所有文件
- if len(items) == 1 and os.path.isdir(os.path.join(extract_to, items[0])):
- sub_dir = os.path.join(extract_to, items[0])
- # 移动子目录中的所有内容到根目录
- for item in os.listdir(sub_dir):
- src = os.path.join(sub_dir, item)
- dst = os.path.join(extract_to, item)
- if os.path.exists(dst):
- if os.path.isdir(dst):
- shutil.rmtree(dst)
- else:
- os.remove(dst)
- shutil.move(src, dst)
- # 尝试删除空子目录
- try:
- os.rmdir(sub_dir)
- except:
- pass
+ # 检查解压目录中是否包含系统目录
+ for item in os.listdir(extract_to):
+ item_path = os.path.join(extract_to, item)
+ if os.path.isdir(item_path):
+ # 检查是否是常见的系统目录
+ if item in ['var', 'etc', 'usr', 'bin', 'sbin', 'lib', 'lib64', 'sys', 'proc', 'dev']:
+ if progress_callback:
+ progress_callback(f"Warning: System directory '{item}' was extracted. This may indicate absolute paths in the archive.", -1)
return True
@@ -1393,6 +1561,17 @@ def _extract_iso_with_system(archive_path, extract_to, progress_callback=None):
if result.returncode != 0:
raise RuntimeError(f"Failed to copy files from ISO: {result.stderr}")
+ # 检查并警告潜在的绝对路径解压问题
+ if os.path.exists(extract_to):
+ # 检查解压目录中是否包含系统目录
+ for item in os.listdir(extract_to):
+ item_path = os.path.join(extract_to, item)
+ if os.path.isdir(item_path):
+ # 检查是否是常见的系统目录
+ if item in ['var', 'etc', 'usr', 'bin', 'sbin', 'lib', 'lib64', 'sys', 'proc', 'dev']:
+ if progress_callback:
+ progress_callback(f"Warning: System directory '{item}' was extracted. This may indicate absolute paths in the archive.", -1)
+
if progress_callback:
progress_callback("ISO extracted successfully", 100)
@@ -1411,6 +1590,17 @@ def _extract_iso_with_system(archive_path, extract_to, progress_callback=None):
if result.returncode != 0:
raise RuntimeError(f"7z extraction failed: {result.stderr}")
+ # 检查并警告潜在的绝对路径解压问题
+ if os.path.exists(extract_to):
+ # 检查解压目录中是否包含系统目录
+ for item in os.listdir(extract_to):
+ item_path = os.path.join(extract_to, item)
+ if os.path.isdir(item_path):
+ # 检查是否是常见的系统目录
+ if item in ['var', 'etc', 'usr', 'bin', 'sbin', 'lib', 'lib64', 'sys', 'proc', 'dev']:
+ if progress_callback:
+ progress_callback(f"Warning: System directory '{item}' was extracted. This may indicate absolute paths in the archive.", -1)
+
if progress_callback:
progress_callback("ISO extracted with 7z", 100)
@@ -1430,6 +1620,17 @@ def _extract_iso_with_system(archive_path, extract_to, progress_callback=None):
if result.returncode != 0:
raise RuntimeError(f"ISO extraction failed: {result.stderr}")
+ # 检查并警告潜在的绝对路径解压问题
+ if os.path.exists(extract_to):
+ # 检查解压目录中是否包含系统目录
+ for item in os.listdir(extract_to):
+ item_path = os.path.join(extract_to, item)
+ if os.path.isdir(item_path):
+ # 检查是否是常见的系统目录
+ if item in ['var', 'etc', 'usr', 'bin', 'sbin', 'lib', 'lib64', 'sys', 'proc', 'dev']:
+ if progress_callback:
+ progress_callback(f"Warning: System directory '{item}' was extracted. This may indicate absolute paths in the archive.", -1)
+
if progress_callback:
progress_callback("ISO extracted successfully", 100)
@@ -1457,6 +1658,17 @@ def _extract_cab_with_cabextract(archive_path, extract_to, progress_callback=Non
if result.returncode != 0:
raise RuntimeError(f"CAB extraction failed: {result.stderr}")
+
+ # 检查并警告潜在的绝对路径解压问题
+ if os.path.exists(extract_to):
+ # 检查解压目录中是否包含系统目录
+ for item in os.listdir(extract_to):
+ item_path = os.path.join(extract_to, item)
+ if os.path.isdir(item_path):
+ # 检查是否是常见的系统目录
+ if item in ['var', 'etc', 'usr', 'bin', 'sbin', 'lib', 'lib64', 'sys', 'proc', 'dev']:
+ if progress_callback:
+ progress_callback(f"Warning: System directory '{item}' was extracted. This may indicate absolute paths in the archive.", -1)
def _extract_with_unar(archive_path, extract_to, progress_callback=None, password=None):
"""Use unar as ultimate fallback"""
@@ -1491,6 +1703,19 @@ def _extract_with_unar(archive_path, extract_to, progress_callback=None, passwor
raise RuntimeError("Password required for encrypted archive")
raise RuntimeError(f"Extraction failed: {result.stderr}")
+ # 即使unar命令成功执行,也需要检查解压结果,防止创建系统目录
+ # 检查是否创建了var目录或其他系统目录
+ system_dirs = ['var', 'etc', 'usr', 'bin', 'sbin', 'lib', 'tmp', 'dev', 'proc', 'sys']
+ for root, dirs, files in os.walk(extract_to):
+ for dir_name in dirs:
+ if dir_name in system_dirs:
+ # 如果发现系统目录,需要检查是否是绝对路径导致的问题
+ full_path = os.path.join(root, dir_name)
+ rel_path = os.path.relpath(full_path, extract_to)
+ # 如果是直接在解压根目录下创建的系统目录,可能是绝对路径问题
+ if os.path.dirname(rel_path) == '' and dir_name in ['var', 'tmp']:
+ print(f"Warning: Detected potential system directory extraction: {rel_path}")
+
# 即使命令成功执行,也需要检查是否真的解压了文件
if not password:
# 检查解压目录是否为空
@@ -2199,6 +2424,155 @@ def _add_7z_with_cli(archive_path, file_to_add_path, progress_callback=None):
raise RuntimeError(f"7z add failed: {result.stderr}")
# Test function
+def batch_extract_archives(archive_paths, extract_to_base, progress_callback=None, password=None,
+ overwrite_existing=False, create_subfolders=True, error_callback=None,
+ password_callback=None, password_detector=None):
+ """
+ Extract multiple archive files in batch.
+
+ Args:
+ archive_paths (list): List of paths to archive files to extract.
+ extract_to_base (str): Base directory to extract all archives to.
+ progress_callback (function): Optional callback for overall progress updates (filename, progress).
+ password (str): Optional password for encrypted archives.
+ overwrite_existing (bool): Whether to overwrite existing files.
+ create_subfolders (bool): Whether to create subfolders for each archive.
+ error_callback (function): Optional callback for individual file error updates (filename, error).
+ password_callback (function): Optional callback to request password from user (archive_path, format, is_protected).
+ password_detector (PasswordDetector): Optional password detector instance for checking protected archives.
+
+ Returns:
+ dict: Results dictionary with 'success_count', 'error_count', 'results' list.
+ """
+ results = {
+ 'success_count': 0,
+ 'error_count': 0,
+ 'results': []
+ }
+
+ # 初始化密码检测器
+ if password_detector is None:
+ try:
+ from password_detector import PasswordDetector
+ password_detector = PasswordDetector()
+ except ImportError:
+ password_detector = None
+
+ # 缓存密码避免重复询问
+ password_cache = {}
+
+ total_archives = len(archive_paths)
+
+ for i, archive_path in enumerate(archive_paths):
+ try:
+ # Calculate overall progress
+ if progress_callback:
+ overall_progress = (i / total_archives) * 100
+ progress_callback(f"Processing {i+1}/{total_archives}: {os.path.basename(archive_path)}", overall_progress)
+
+ # Determine extraction directory for this archive
+ if create_subfolders:
+ # Create subfolder named after archive (without extension)
+ archive_name = os.path.splitext(os.path.basename(archive_path))[0]
+ archive_extract_to = os.path.join(extract_to_base, archive_name)
+ else:
+ # Extract directly to base directory
+ archive_extract_to = extract_to_base
+
+ os.makedirs(archive_extract_to, exist_ok=True)
+
+ # 检测密码保护状态
+ current_password = password
+ if password_detector and password_callback:
+ try:
+ detection_result = password_detector.is_password_protected(archive_path)
+ if detection_result['is_protected']:
+ archive_format = detection_result.get('format', 'unknown')
+
+ # 检查是否已有该格式的密码
+ if archive_format not in password_cache:
+ # 询问用户输入密码
+ if progress_callback:
+ progress_callback(f"检测到密码保护的 {archive_format.upper()} 文件: {os.path.basename(archive_path)}",
+ (i / total_archives) * 100)
+
+ requested_password = password_callback(archive_path, archive_format, True)
+ if requested_password:
+ password_cache[archive_format] = requested_password
+ current_password = requested_password
+ else:
+ # 用户取消操作
+ results['error_count'] += 1
+ error_msg = "用户取消密码输入"
+ if error_callback:
+ error_callback(archive_path, error_msg)
+ results['results'].append({
+ 'archive_path': archive_path,
+ 'extract_to': archive_extract_to,
+ 'status': 'cancelled',
+ 'error': error_msg
+ })
+ continue
+ else:
+ current_password = password_cache[archive_format]
+ elif detection_result.get('error'):
+ # 检测出错,但仍然尝试正常解压
+ if error_callback:
+ error_callback(archive_path, f"密码检测失败: {detection_result['error']}")
+ except Exception as e:
+ # 密码检测失败,继续尝试正常解压
+ if error_callback:
+ error_callback(archive_path, f"密码检测出错: {str(e)}")
+
+ # Create individual progress callback for this archive
+ def archive_progress(message, percent):
+ if progress_callback:
+ # Combine overall progress with archive-specific progress
+ archive_progress_value = (i + percent/100) / total_archives * 100
+ progress_callback(f"{os.path.basename(archive_path)}: {message}", archive_progress_value)
+
+ # Extract the archive
+ success = extract_archive(archive_path, archive_extract_to,
+ progress_callback=archive_progress, password=current_password)
+
+ if success:
+ results['success_count'] += 1
+ results['results'].append({
+ 'archive_path': archive_path,
+ 'extract_to': archive_extract_to,
+ 'status': 'success',
+ 'error': None
+ })
+ else:
+ results['error_count'] += 1
+ error_msg = f"Extraction failed"
+ if error_callback:
+ error_callback(archive_path, error_msg)
+ results['results'].append({
+ 'archive_path': archive_path,
+ 'extract_to': archive_extract_to,
+ 'status': 'error',
+ 'error': error_msg
+ })
+
+ except Exception as e:
+ results['error_count'] += 1
+ error_msg = str(e)
+ if error_callback:
+ error_callback(archive_path, error_msg)
+ results['results'].append({
+ 'archive_path': archive_path,
+ 'extract_to': archive_extract_to if 'archive_extract_to' in locals() else extract_to_base,
+ 'status': 'error',
+ 'error': error_msg
+ })
+
+ # Final progress update
+ if progress_callback:
+ progress_callback(results['success_count'], total_archives, f"Batch extraction complete: {results['success_count']}/{total_archives} successful")
+
+ return results
+
def test_archive_functions():
"""Test archive functions"""
import tempfile
@@ -2282,5 +2656,80 @@ def test_archive_functions():
print("\n=== Archive Functions Test Complete ===")
+def _set_executable_permissions(extract_to, progress_callback=None):
+ """
+ 为解压出的可执行文件添加执行权限
+
+ Args:
+ extract_to (str): 解压目标目录
+ progress_callback (function): 可选的进度回调函数
+ """
+ import stat
+ import re
+
+ # 定义可执行文件的常见扩展名
+ executable_extensions = {
+ '.sh', '.bash', '.zsh', '.fish', '.cmd', '.bat', # Shell脚本
+ '.py', '.pl', '.rb', '.php', '.js', '.ts', # 脚本语言
+ '.exe', '.app', '.bin', '.com', # 可执行文件
+ '.run', '.install', '.setup' # 安装程序
+ }
+
+ # 需要检查shebang的文件扩展名
+ script_extensions = {'.sh', '.bash', '.zsh', '.fish', '.py', '.pl', '.rb', '.php', '.js', '.ts'}
+
+ executable_files = []
+
+ def check_file_executable(file_path):
+ """检查文件是否可能是可执行文件"""
+ # 检查文件扩展名
+ _, ext = os.path.splitext(file_path.lower())
+ if ext in executable_extensions:
+ return True
+
+ # 对于没有后缀名的文件,直接返回True
+ if not ext:
+ return True
+
+ # 检查shebang行(对于脚本文件)
+ if ext in script_extensions:
+ try:
+ with open(file_path, 'rb') as f:
+ first_line = f.readline(100) # 读取前100字节
+ if first_line.startswith(b'#!'):
+ return True
+ except (IOError, UnicodeDecodeError):
+ # 如果无法读取文件,跳过
+ pass
+
+ return False
+
+ # 遍历解压目录中的所有文件
+ for root, dirs, files in os.walk(extract_to):
+ for file_name in files:
+ file_path = os.path.join(root, file_name)
+
+ # 跳过隐藏文件和系统文件
+ if file_name.startswith('.') or file_name in ['.DS_Store', 'Thumbs.db']:
+ continue
+
+ try:
+ if check_file_executable(file_path):
+ # 添加执行权限 (755: rwxr-xr-x)
+ current_stat = os.stat(file_path)
+ os.chmod(file_path, current_stat.st_mode | stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH)
+ executable_files.append(file_path)
+
+ if progress_callback:
+ progress_callback(f"Set executable permission: {file_name}", -1) # -1表示不更新总体进度
+
+ except (OSError, IOError) as e:
+ # 权限设置失败,记录但不影响解压
+ if progress_callback:
+ progress_callback(f"Warning: Failed to set permission for {file_name}: {str(e)}", -1)
+
+ if progress_callback and executable_files:
+ progress_callback(f"Set executable permissions for {len(executable_files)} files", -1)
+
if __name__ == "__main__":
test_archive_functions()
\ No newline at end of file
diff --git a/support/convert.py b/support/convert.py
index 437d580..713092d 100644
--- a/support/convert.py
+++ b/support/convert.py
@@ -45,27 +45,46 @@ def convert_image(input_path, output_path, output_format, min_size=16, max_size=
quality (int): Image quality for lossy formats like JPG (default: 85, range: 1-100).
progress_callback (function): Callback function to report progress.
interface_settings (dict): Interface behavior settings for controlling conversion behavior.
+
+ Returns:
+ tuple: (success, message) where success is a boolean and message is a string
"""
- if output_format not in SUPPORTED_FORMATS:
- raise ValueError(f"Unsupported output format: {output_format}. Supported formats are: {', '.join(SUPPORTED_FORMATS)}")
+ try:
+ if output_format not in SUPPORTED_FORMATS:
+ error_msg = f"Unsupported output format: {output_format}. Supported formats are: {', '.join(SUPPORTED_FORMATS)}"
+ if progress_callback:
+ progress_callback(error_msg, 0)
+ return False, error_msg
- if output_format == "icns":
- # Existing ICNS conversion logic
- _create_icns_internal(input_path, output_path, min_size, max_size, progress_callback)
- else:
- # Generic image conversion using Pillow
- try:
- img = Image.open(input_path)
-
- # For JPG, ensure the image is in RGB mode as JPG does not support alpha channel
- if output_format.lower() == "jpg":
- if progress_callback:
- progress_callback(f"Processing image for JPG conversion...", 30)
+ if not os.path.exists(input_path):
+ error_msg = f"Input file not found: {input_path}"
+ if progress_callback:
+ progress_callback(error_msg, 0)
+ return False, error_msg
+
+ if output_format == "icns":
+ # Existing ICNS conversion logic
+ _create_icns_internal(input_path, output_path, min_size, max_size, progress_callback)
+ else:
+ # Generic image conversion using Pillow
+ img = None
+ try:
+ img = Image.open(input_path)
+
+ # Create output directory if it doesn't exist
+ output_dir = os.path.dirname(output_path)
+ if output_dir and not os.path.exists(output_dir):
+ os.makedirs(output_dir, exist_ok=True)
- # Handle various image modes that need conversion to RGB
- if img.mode in ('RGBA', 'LA', 'P', 'CMYK', 'YCbCr', 'LAB', 'HSV', 'I', 'F'):
+ # For JPG, ensure the image is in RGB mode as JPG does not support alpha channel
+ if output_format.lower() == "jpg":
if progress_callback:
- progress_callback(f"Converting from {img.mode} mode to RGB...", 40)
+ progress_callback(f"Processing image for JPG conversion...", 30)
+
+ # Handle various image modes that need conversion to RGB
+ if img.mode in ('RGBA', 'LA', 'P', 'CMYK', 'YCbCr', 'LAB', 'HSV', 'I', 'F'):
+ if progress_callback:
+ progress_callback(f"Converting from {img.mode} mode to RGB...", 40)
# For modes with transparency, use white background
if img.mode in ('RGBA', 'LA', 'P'):
@@ -78,237 +97,299 @@ def convert_image(input_path, output_path, output_format, min_size=16, max_size=
else:
# For other modes, convert directly to RGB
img = img.convert('RGB')
+
+ if progress_callback:
+ progress_callback(f"Image converted to RGB mode", 50)
- if progress_callback:
- progress_callback(f"Image converted to RGB mode", 50)
-
- # Save with appropriate options for each format
- save_options = {}
-
- if output_format.lower() == "jpg":
- # Save JPG with specified quality and optimized settings
- img.save(output_path, format='JPEG', quality=quality, optimize=True, progressive=True)
- elif output_format.lower() == "webp":
- # Save WebP with quality settings
- save_options = {'quality': quality, 'method': 6}
- img.save(output_path, format='WEBP', **save_options)
- elif output_format.lower() == "tiff":
- # Save TIFF with compression
- save_options = {'compression': 'tiff_lzw'}
- img.save(output_path, format='TIFF', **save_options)
- elif output_format.lower() in ["svg", "pdf", "eps"]:
- # Vector formats require special handling
- # For now, convert raster image to these formats with basic settings
- if progress_callback:
- progress_callback(f"Converting to vector format {output_format.upper()}...", 70)
- img.save(output_path, format=output_format.upper())
- elif output_format.lower() in ["heic", "heif"]:
- # HEIC/HEIF format support
- if progress_callback:
- progress_callback(f"Converting to {output_format.upper()} format...", 70)
- # Try to save as HEIF if available, otherwise fallback
- try:
- img.save(output_path, format='HEIF', quality=quality)
- except Exception:
- # Fallback to PNG if HEIF not supported
+ # Save with appropriate options for each format
+ save_options = {}
+
+ if output_format.lower() == "jpg":
+ # Save JPG with specified quality and optimized settings
+ img.save(output_path, format='JPEG', quality=quality, optimize=True, progressive=True)
+ elif output_format.lower() == "webp":
+ # Save WebP with quality settings
+ save_options = {'quality': quality, 'method': 6}
+ img.save(output_path, format='WEBP', **save_options)
+ elif output_format.lower() == "tiff":
+ # Save TIFF with compression
+ save_options = {'compression': 'tiff_lzw'}
+ img.save(output_path, format='TIFF', **save_options)
+ elif output_format.lower() in ["svg", "pdf", "eps"]:
+ # Vector formats require special handling
+ # For now, convert raster image to these formats with basic settings
if progress_callback:
- progress_callback(f"HEIF format not available, falling back to PNG", 80)
- img.save(output_path, format='PNG')
- elif output_format.lower() in ["avif", "jxl"]:
- # Modern formats that may require additional libraries
- if progress_callback:
- progress_callback(f"Converting to {output_format.upper()} format...", 70)
- try:
- img.save(output_path, format=output_format.upper(), quality=quality)
- except Exception as format_error:
- # Fallback to WebP if modern format not supported
+ progress_callback(f"Converting to vector format {output_format.upper()}...", 70)
+ img.save(output_path, format=output_format.upper())
+ elif output_format.lower() in ["heic", "heif"]:
+ # HEIC/HEIF format support
if progress_callback:
- progress_callback(f"{output_format.upper()} format not available, falling back to WebP", 80)
- img.save(output_path, format='WEBP', quality=quality)
- elif output_format.lower() in ["dds", "exr"]:
- # Specialized formats for gaming and HDR
- if progress_callback:
- progress_callback(f"Converting to {output_format.upper()} format...", 70)
- img.save(output_path, format=output_format.upper())
- else:
- # Default handling for other formats
- img.save(output_path, format=output_format.upper())
+ progress_callback(70, 100, f"Converting to {output_format.upper()} format...")
+ # Try to save as HEIF if available, otherwise fallback
+ try:
+ img.save(output_path, format='HEIF', quality=quality)
+ except Exception:
+ # Fallback to PNG if HEIF not supported
+ if progress_callback:
+ progress_callback(f"HEIF format not available, falling back to PNG", 80)
+ img.save(output_path, format='PNG')
+ elif output_format.lower() in ["avif", "jxl"]:
+ # Modern formats that may require additional libraries
+ if progress_callback:
+ progress_callback(f"Converting to {output_format.upper()} format...", 70)
+ try:
+ img.save(output_path, format=output_format.upper(), quality=quality)
+ except Exception as format_error:
+ # Fallback to WebP if modern format not supported
+ if progress_callback:
+ progress_callback(f"{output_format.upper()} format not available, falling back to WebP", 80)
+ img.save(output_path, format='WEBP', quality=quality)
+ elif output_format.lower() in ["dds", "exr"]:
+ # Specialized formats for gaming and HDR
+ if progress_callback:
+ progress_callback(f"Converting to {output_format.upper()} format...", 70)
+ img.save(output_path, format=output_format.upper())
+ else:
+ # Default handling for other formats
+ img.save(output_path, format=output_format.upper())
+ finally:
+ # Ensure image resources are released
+ if img is not None:
+ img.close()
+ del img
if progress_callback:
- progress_callback(f"Successfully converted {input_path} to {output_path} ({output_format})", 100)
+ progress_callback(100, 100, f"Successfully converted {input_path} to {output_path} ({output_format})")
else:
print(f"Successfully converted {input_path} to {output_path} ({output_format})")
- except Exception as e:
- error_msg = f"Error converting image to {output_format.upper()}: {e}"
- if progress_callback:
- progress_callback(error_msg, 0)
- raise Exception(error_msg) from e
+
+ return True, f"Successfully converted {os.path.basename(input_path)} to {output_format.upper()}"
+ except Exception as e:
+ error_msg = f"Error converting image {os.path.basename(input_path)}: {str(e)}"
+ if progress_callback:
+ progress_callback(error_msg, 0)
+ return False, error_msg
def _create_icns_internal(png_path, icns_path, min_size=16, max_size=None, progress_callback=None):
"""
Internal function to convert a PNG image to ICNS format using iconset method.
This function contains the original logic of create_icns.
"""
- # Open the source image
- img = Image.open(png_path)
-
- # Automatically detect image size if not provided
- if max_size is None:
- max_size = min(img.width, img.height)
- if progress_callback:
- progress_callback(f"Auto-detected maximum size: {max_size}", 5)
- else:
- print(f"Auto-detected maximum size: {max_size}")
-
- if progress_callback:
- progress_callback(f"Source image size: {img.width}x{img.height}", 10)
- else:
- print(f"Source image size: {img.width}x{img.height}")
-
- # Ensure the image is square
- if img.width != img.height:
+ img = None
+ try:
+ # Open the source image
+ img = Image.open(png_path)
+
+ # Automatically detect image size if not provided
+ if max_size is None:
+ max_size = min(img.width, img.height)
+ if progress_callback:
+ progress_callback(5, 100, f"Auto-detected maximum size: {max_size}")
+ else:
+ print(f"Auto-detected maximum size: {max_size}")
+
if progress_callback:
- progress_callback("Warning: Image is not square. Cropping to square.", 15)
+ progress_callback(10, 100, f"Source image size: {img.width}x{img.height}")
else:
- print("Warning: Image is not square. Cropping to square.")
- min_dimension = min(img.width, img.height)
- left = (img.width - min_dimension) // 2
- top = (img.height - min_dimension) // 2
- right = left + min_dimension
- bottom = top + min_dimension
- img = img.crop((left, top, right, bottom))
-
- # Create a temporary directory for iconset
- with tempfile.TemporaryDirectory() as tmp_dir:
- iconset_dir = os.path.join(tmp_dir, "iconset.iconset")
- os.makedirs(iconset_dir)
+ print(f"Source image size: {img.width}x{img.height}")
- # Define standard sizes for ICNS (based on Apple's specifications)
- standard_sizes = [16, 32, 64, 128, 256, 512, 1024]
-
- # Generate icons for standard sizes within our range
- generated_sizes = []
- total_steps = len(standard_sizes) * 2 # Approximate steps
- current_step = 0
+ # Ensure the image is square
+ if img.width != img.height:
+ if progress_callback:
+ progress_callback(15, 100, "Warning: Image is not square. Cropping to square.")
+ else:
+ print("Warning: Image is not square. Cropping to square.")
+ min_dimension = min(img.width, img.height)
+ left = (img.width - min_dimension) // 2
+ top = (img.height - min_dimension) // 2
+ right = left + min_dimension
+ bottom = top + min_dimension
+ img = img.crop((left, top, right, bottom))
- for size in standard_sizes:
- if min_size <= size <= max_size:
- current_step += 1
- if progress_callback:
- progress_callback(f"Generating size: {size}x{size}", 20 + int(60 * current_step / total_steps))
- else:
- print(f"Generated size: {size}x{size}")
-
- # Create a copy of the image and resize it
- resized_img = img.copy().resize((size, size), Image.Resampling.LANCZOS)
-
- # Save as PNG in iconset directory
- filename = f"icon_{size}x{size}.png"
- resized_img.save(os.path.join(iconset_dir, filename), "PNG")
- generated_sizes.append(size)
-
- # For specific sizes, also generate retina versions
- retina_pairs = {16: 32, 32: 64, 128: 256, 256: 512, 512: 1024}
- if size in retina_pairs and retina_pairs[size] <= max_size:
+ # Create a temporary directory for iconset
+ with tempfile.TemporaryDirectory() as tmp_dir:
+ iconset_dir = os.path.join(tmp_dir, "iconset.iconset")
+ os.makedirs(iconset_dir)
+
+ # Define standard sizes for ICNS (based on Apple's specifications)
+ standard_sizes = [16, 32, 64, 128, 256, 512, 1024]
+
+ # Generate icons for standard sizes within our range
+ generated_sizes = []
+ total_steps = len(standard_sizes) * 2 # Approximate steps
+ current_step = 0
+
+ for size in standard_sizes:
+ if min_size <= size <= max_size:
current_step += 1
- retina_size = retina_pairs[size]
if progress_callback:
- progress_callback(f"Generating retina size: {retina_size}x{retina_size}", 20 + int(60 * current_step / total_steps))
+ progress_callback(20 + int(60 * current_step / total_steps), 100, f"Generating size: {size}x{size}")
else:
- print(f"Generated retina size: {retina_size}x{retina_size}")
+ print(f"Generated size: {size}x{size}")
- retina_img = img.copy().resize((retina_size, retina_size), Image.Resampling.LANCZOS)
- retina_filename = f"icon_{size}x{size}@2x.png"
- retina_img.save(os.path.join(iconset_dir, retina_filename), "PNG")
- generated_sizes.append(retina_size)
-
- # Make sure we include max_size if it's not already included
- if max_size not in generated_sizes:
- if progress_callback:
- progress_callback(f"Generating max size: {max_size}x{max_size}", 85)
- else:
- print(f"Generated max size: {max_size}x{max_size}")
+ # Create a copy of the image and resize it
+ resized_img = img.copy().resize((size, size), Image.Resampling.LANCZOS)
+
+ # Save as PNG in iconset directory
+ filename = f"icon_{size}x{size}.png"
+ resized_img.save(os.path.join(iconset_dir, filename), "PNG")
+ generated_sizes.append(size)
+
+ # For specific sizes, also generate retina versions
+ retina_pairs = {16: 32, 32: 64, 128: 256, 256: 512, 512: 1024}
+ if size in retina_pairs and retina_pairs[size] <= max_size:
+ current_step += 1
+ retina_size = retina_pairs[size]
+ if progress_callback:
+ progress_callback(20 + int(60 * current_step / total_steps), 100, f"Generating retina size: {retina_size}x{retina_size}")
+ else:
+ print(f"Generated retina size: {retina_size}x{retina_size}")
+
+ retina_img = img.copy().resize((retina_size, retina_size), Image.Resampling.LANCZOS)
+ retina_filename = f"icon_{size}x{size}@2x.png"
+ retina_img.save(os.path.join(iconset_dir, retina_filename), "PNG")
+ generated_sizes.append(retina_size)
- resized_img = img.copy().resize((max_size, max_size), Image.Resampling.LANCZOS)
- filename = f"icon_{max_size}x{max_size}.png"
- resized_img.save(os.path.join(iconset_dir, filename), "PNG")
- generated_sizes.append(max_size)
-
- # Use iconutil to create ICNS file (macOS only)
- if progress_callback:
- progress_callback("Creating ICNS file with iconutil...", 90)
- else:
- print("Creating ICNS file with iconutil...")
+ # Make sure we include max_size if it's not already included
+ if max_size not in generated_sizes:
+ if progress_callback:
+ progress_callback(85, 100, f"Generating max size: {max_size}x{max_size}")
+ else:
+ print(f"Generated max size: {max_size}x{max_size}")
+
+ resized_img = img.copy().resize((max_size, max_size), Image.Resampling.LANCZOS)
+ filename = f"icon_{max_size}x{max_size}.png"
+ resized_img.save(os.path.join(iconset_dir, filename), "PNG")
+ generated_sizes.append(max_size)
- try:
- subprocess.run(["iconutil", "-c", "icns", iconset_dir, "-o", icns_path], check=True)
+ # Use iconutil to create ICNS file (macOS only)
if progress_callback:
- progress_callback(f"Successfully converted {png_path} to {icns_path}", 100)
+ progress_callback(90, 100, "Creating ICNS file with iconutil...")
else:
- print(f"Successfully converted {png_path} to {icns_path}")
- print(f"Generated sizes: {sorted(set(generated_sizes))}")
- except subprocess.CalledProcessError as e:
- error_detail = e.stderr.decode() if e.stderr else str(e)
- error_msg = f"Error creating ICNS with iconutil: {error_detail}"
+ print("Creating ICNS file with iconutil...")
+
+ try:
+ subprocess.run(["iconutil", "-c", "icns", iconset_dir, "-o", icns_path], check=True, capture_output=True, text=True)
+ if progress_callback:
+ progress_callback(100, 100, f"Successfully converted {png_path} to {icns_path}")
+ else:
+ print(f"Successfully converted {png_path} to {icns_path}")
+ print(f"Generated sizes: {sorted(set(generated_sizes))}")
+ except subprocess.CalledProcessError as e:
+ error_detail = e.stderr if e.stderr else str(e)
+ error_msg = f"Error creating ICNS with iconutil: {error_detail}"
+ if progress_callback:
+ progress_callback(90, 100, error_msg)
+ progress_callback(90, 100, "Falling back to Pillow method...")
+ else:
+ print(error_msg)
+ print("Falling back to Pillow method...")
+ # Fallback to Pillow method if iconutil fails
+ _fallback_method_internal(iconset_dir, icns_path, progress_callback)
+ except Exception as e:
+ error_msg = f"Unexpected error during iconutil conversion: {e}"
+ if progress_callback:
+ progress_callback(90, 100, error_msg)
+ progress_callback(90, 100, "Falling back to Pillow method...")
+ else:
+ print(error_msg)
+ print("Falling back to Pillow method...")
+ _fallback_method_internal(iconset_dir, icns_path, progress_callback)
+ finally:
+ # Ensure image resources are released
+ if img is not None:
+ img.close()
+ del img
+
+def _fallback_method_internal(iconset_dir, icns_path, progress_callback=None):
+ """
+ Internal fallback method using Pillow if iconutil is not available
+ """
+ try:
+ icon_files = os.listdir(iconset_dir)
+ if not icon_files:
+ error_msg = "No icons generated, cannot create ICNS file"
if progress_callback:
- progress_callback(error_msg, 90)
- progress_callback("Falling back to Pillow method...", 90)
+ progress_callback(100, 100, error_msg)
else:
print(error_msg)
- print("Falling back to Pillow method...")
- # Fallback to Pillow method if iconutil fails
- _fallback_method_internal(iconset_dir, icns_path, progress_callback)
- except Exception as e:
- error_msg = f"Unexpected error during iconutil conversion: {e}"
+ return
+
+ # Find the largest icon file to use as main image
+ icon_paths = [os.path.join(iconset_dir, f) for f in icon_files]
+
+ # Get sizes of all icons
+ icon_sizes = []
+ for icon_path in icon_paths:
+ img = None
+ try:
+ img = Image.open(icon_path)
+ icon_sizes.append((img.size[0], icon_path))
+ finally:
+ if img is not None:
+ img.close()
+ del img
+
+ if not icon_sizes:
+ error_msg = "Could not read any icon files for fallback method"
if progress_callback:
- progress_callback(error_msg, 90)
- progress_callback("Falling back to Pillow method...", 90)
+ progress_callback(100, 100, error_msg)
else:
print(error_msg)
- print("Falling back to Pillow method...")
- _fallback_method_internal(iconset_dir, icns_path, progress_callback)
-
-def _fallback_method_internal(iconset_dir, icns_path, progress_callback=None):
- """
- Internal fallback method using Pillow if iconutil is not available
- """
- icon_files = os.listdir(iconset_dir)
- if not icon_files:
- error_msg = "No icons generated, cannot create ICNS file"
+ return
+
+ # Find the largest icon
+ largest_icon_path = max(icon_sizes, key=lambda x: x[0])[1]
+
+ # Open the largest icon as main image
+ main_img = None
+ append_images = []
+ try:
+ main_img = Image.open(largest_icon_path)
+
+ # Create list of additional images
+ for icon_path in icon_paths:
+ if icon_path != largest_icon_path:
+ img = None
+ try:
+ img = Image.open(icon_path)
+ append_images.append(img)
+ except Exception as e:
+ # Skip individual images that can't be opened
+ print(f"Warning: Could not open icon {icon_path} for fallback: {e}")
+ if img is not None:
+ img.close()
+ del img
+
+ # Save as ICNS
+ if append_images:
+ main_img.save(
+ icns_path,
+ format='ICNS',
+ append_images=append_images
+ )
+ else:
+ main_img.save(icns_path, format='ICNS')
+ finally:
+ # Ensure all image resources are released
+ if main_img is not None:
+ main_img.close()
+ del main_img
+ for img in append_images:
+ img.close()
+ del img
+
+ success_msg = f"Successfully converted using fallback method to {icns_path}"
if progress_callback:
- progress_callback(error_msg, 100)
+ progress_callback(100, 100, success_msg)
+ else:
+ print(success_msg)
+ except Exception as e:
+ error_msg = f"Error in fallback method: {e}"
+ if progress_callback:
+ progress_callback(100, 100, error_msg)
else:
print(error_msg)
- return
-
- # Find the largest icon file to use as main image
- icon_paths = [os.path.join(iconset_dir, f) for f in icon_files]
- largest_icon = max(icon_paths, key=lambda p: Image.open(p).size[0])
-
- # Open the largest icon as main image
- main_img = Image.open(largest_icon)
-
- # Create list of additional images
- append_images = []
- for icon_path in icon_paths:
- if icon_path != largest_icon:
- append_images.append(Image.open(icon_path))
-
- # Save as ICNS
- if append_images:
- main_img.save(
- icns_path,
- format='ICNS',
- append_images=append_images
- )
- else:
- main_img.save(icns_path, format='ICNS')
-
- success_msg = f"Successfully converted using fallback method to {icns_path}"
- if progress_callback:
- progress_callback(success_msg, 100)
- else:
- print(success_msg)
def main():
parser = argparse.ArgumentParser(description="Convert images to various formats (ICNS, PNG, JPG, WebP)")
diff --git a/support/notification.py b/support/notification.py
new file mode 100644
index 0000000..694fc15
--- /dev/null
+++ b/support/notification.py
@@ -0,0 +1,75 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+System-level notification module for Converter application
+Supports macOS, Windows, and Linux platforms
+"""
+
+import platform
+import subprocess
+import sys
+
+
+def send_notification(title, message):
+ """Send system-level notification
+
+ Args:
+ title (str): Notification title
+ message (str): Notification content
+ """
+ system = platform.system()
+
+ try:
+ if system == "Darwin":
+ # macOS: Use terminal-notifier or osascript
+ try:
+ # Try terminal-notifier first (if installed)
+ subprocess.run([
+ "terminal-notifier",
+ "-title", title,
+ "-message", message,
+ "-appIcon", "Terminal"
+ ], check=True, capture_output=True)
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ # Fallback to osascript (built-in)
+ script = f'display notification "{message}" with title "{title}"'
+ subprocess.run(["osascript", "-e", script], check=True, capture_output=True)
+
+ elif system == "Windows":
+ # Windows: Use win10toast if available, otherwise fallback
+ try:
+ from win10toast import ToastNotifier
+ toaster = ToastNotifier()
+ toaster.show_toast(title, message, duration=5)
+ except ImportError:
+ # Fallback to Windows PowerShell
+ script = f'''Add-Type -AssemblyName System.Windows.Forms;
+ [System.Windows.Forms.MessageBox]::Show("{message}", "{title}",
+ [System.Windows.Forms.MessageBoxButtons]::OK,
+ [System.Windows.Forms.MessageBoxIcon]::Information)'''
+ subprocess.run(["powershell", "-Command", script], check=True, capture_output=True)
+
+ elif system == "Linux":
+ # Linux: Use notify-send (most desktop environments)
+ subprocess.run([
+ "notify-send",
+ "-a", "Converter",
+ title,
+ message
+ ], check=True, capture_output=True)
+
+ else:
+ # Unsupported platform
+ print(f"[Notification] {title}: {message}")
+
+ except subprocess.CalledProcessError as e:
+ # Log error but don't crash the application
+ print(f"Failed to send notification: {e}")
+ except Exception as e:
+ # Catch all other exceptions
+ print(f"Unexpected error sending notification: {e}")
+
+
+if __name__ == "__main__":
+ # Test notification functionality
+ send_notification("Test Notification", "This is a test notification from Converter")
diff --git a/support/task_manager.py b/support/task_manager.py
new file mode 100644
index 0000000..268405e
--- /dev/null
+++ b/support/task_manager.py
@@ -0,0 +1,243 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+Task manager module for Converter application
+Handles task queue, concurrency, and progress tracking
+"""
+
+from concurrent.futures import ThreadPoolExecutor, as_completed
+import uuid
+import time
+from PySide6.QtCore import QObject, Signal, QThread
+import threading
+
+
+class TaskStatus:
+ """Enum for task status"""
+ PENDING = "pending"
+ RUNNING = "running"
+ COMPLETED = "completed"
+ FAILED = "failed"
+ CANCELLED = "cancelled"
+
+
+class Task:
+ """Task class to represent a conversion task"""
+
+ def __init__(self, task_type, input_path, output_path, **kwargs):
+ self.task_id = str(uuid.uuid4())
+ self.task_type = task_type # "image" or "arc"
+ self.input_path = input_path
+ self.output_path = output_path
+ self.status = TaskStatus.PENDING
+ self.progress = 0
+ self.start_time = None
+ self.end_time = None
+ self.result = None
+ self.error = None
+ self.metadata = kwargs
+
+ def __str__(self):
+ return f"Task {self.task_id}: {self.task_type} - {self.status} ({self.progress}%)"
+
+
+class TaskManager(QObject):
+ """Task manager to handle task queue and concurrency"""
+
+ # Signals for progress and status updates
+ task_added = Signal(str, dict) # task_id, task_info
+ task_updated = Signal(str, dict) # task_id, task_info
+ task_completed = Signal(str, dict) # task_id, task_info
+ task_failed = Signal(str, dict, str) # task_id, task_info, error
+ progress_updated = Signal(str, int) # task_id, progress
+
+ def __init__(self, max_workers=4):
+ super().__init__()
+ self.tasks = {}
+ self.task_queue = []
+ self.executor = ThreadPoolExecutor(max_workers=max_workers)
+ self.futures = {}
+ self.is_running = False
+ self.lock = threading.Lock()
+
+ def add_task(self, task_type, input_path, output_path, **kwargs):
+ """Add a new task to the queue"""
+ task = Task(task_type, input_path, output_path, **kwargs)
+
+ with self.lock:
+ self.tasks[task.task_id] = task
+ self.task_queue.append(task)
+
+ # Emit signal
+ self.task_added.emit(task.task_id, self._get_task_info(task))
+
+ # Start processing if not already running
+ if not self.is_running:
+ self._start_processing()
+
+ return task.task_id
+
+ def _start_processing(self):
+ """Start processing tasks from the queue"""
+ if self.is_running:
+ return
+
+ self.is_running = True
+
+ def process_queue():
+ while True:
+ with self.lock:
+ if not self.task_queue:
+ self.is_running = False
+ break
+
+ # Get next task
+ task = self.task_queue.pop(0)
+ task.status = TaskStatus.RUNNING
+ task.start_time = time.time()
+
+ # Emit task updated signal
+ self.task_updated.emit(task.task_id, self._get_task_info(task))
+
+ # Submit task to executor
+ future = self.executor.submit(self._execute_task, task)
+ self.futures[future] = task.task_id
+
+ # Handle task completion
+ future.add_done_callback(self._handle_task_completion)
+
+ # Start processing in a separate thread to avoid blocking the main thread
+ threading.Thread(target=process_queue, daemon=True).start()
+
+ def _execute_task(self, task):
+ """Execute a task"""
+ try:
+ # Simulate task execution with progress updates
+ # In real implementation, this would call the actual conversion function
+ for i in range(101):
+ time.sleep(0.1) # Simulate work
+ task.progress = i
+ self.progress_updated.emit(task.task_id, i)
+
+ task.status = TaskStatus.COMPLETED
+ task.end_time = time.time()
+ task.result = "Success"
+
+ return task
+
+ except Exception as e:
+ task.status = TaskStatus.FAILED
+ task.end_time = time.time()
+ task.error = str(e)
+ raise
+
+ def _handle_task_completion(self, future):
+ """Handle task completion"""
+ task_id = self.futures.pop(future)
+
+ try:
+ task = future.result()
+ task_info = self._get_task_info(task)
+
+ if task.status == TaskStatus.COMPLETED:
+ self.task_completed.emit(task_id, task_info)
+ else:
+ self.task_failed.emit(task_id, task_info, task.error)
+
+ except Exception as e:
+ with self.lock:
+ task = self.tasks.get(task_id)
+ if task:
+ task.status = TaskStatus.FAILED
+ task.end_time = time.time()
+ task.error = str(e)
+
+ if task:
+ task_info = self._get_task_info(task)
+ self.task_failed.emit(task_id, task_info, str(e))
+
+ # Emit task updated signal
+ with self.lock:
+ if task_id in self.tasks:
+ task = self.tasks[task_id]
+ self.task_updated.emit(task_id, self._get_task_info(task))
+
+ def _get_task_info(self, task):
+ """Get task information as a dictionary"""
+ return {
+ "task_id": task.task_id,
+ "task_type": task.task_type,
+ "input_path": task.input_path,
+ "output_path": task.output_path,
+ "status": task.status,
+ "progress": task.progress,
+ "start_time": task.start_time,
+ "end_time": task.end_time,
+ "result": task.result,
+ "error": task.error,
+ "metadata": task.metadata
+ }
+
+ def get_task(self, task_id):
+ """Get a task by its ID"""
+ with self.lock:
+ return self.tasks.get(task_id)
+
+ def get_all_tasks(self):
+ """Get all tasks"""
+ with self.lock:
+ return list(self.tasks.values())
+
+ def get_active_tasks(self):
+ """Get all active tasks"""
+ with self.lock:
+ return [task for task in self.tasks.values()
+ if task.status in [TaskStatus.PENDING, TaskStatus.RUNNING]]
+
+ def get_completed_tasks(self):
+ """Get all completed tasks"""
+ with self.lock:
+ return [task for task in self.tasks.values()
+ if task.status == TaskStatus.COMPLETED]
+
+ def cancel_task(self, task_id):
+ """Cancel a task"""
+ with self.lock:
+ if task_id in self.tasks:
+ task = self.tasks[task_id]
+ if task.status in [TaskStatus.PENDING, TaskStatus.RUNNING]:
+ task.status = TaskStatus.CANCELLED
+ # Remove from queue if pending
+ if task in self.task_queue:
+ self.task_queue.remove(task)
+ return True
+ return False
+
+ def retry_task(self, task_id):
+ """Retry a failed or cancelled task"""
+ with self.lock:
+ if task_id in self.tasks:
+ old_task = self.tasks[task_id]
+ if old_task.status in [TaskStatus.FAILED, TaskStatus.CANCELLED]:
+ new_task = Task(
+ old_task.task_type,
+ old_task.input_path,
+ old_task.output_path,
+ **old_task.metadata
+ )
+ self.tasks[new_task.task_id] = new_task
+ self.task_queue.append(new_task)
+ return new_task.task_id
+ return None
+
+ def clear_completed_tasks(self):
+ """Clear all completed tasks"""
+ with self.lock:
+ completed_ids = [task.task_id for task in self.tasks.values()
+ if task.status in [TaskStatus.COMPLETED, TaskStatus.FAILED, TaskStatus.CANCELLED]]
+ for task_id in completed_ids:
+ del self.tasks[task_id]
+
+ def shutdown(self):
+ """Shutdown the task manager"""
+ self.executor.shutdown(wait=True)
diff --git a/update/README.md b/update/README.md
index ef2a947..3d450fa 100644
--- a/update/README.md
+++ b/update/README.md
@@ -41,8 +41,8 @@ from update.download_update import download_and_apply_update
# 假设从UpdateManager获取到了更新信息
update_info = {
"status": "update_available",
- "download_url": "https://github.com/user/repo/releases/tag/v2.0.0",
- "latest_version": "v2.0.0" # 注意:需要完整的tag名称
+ "download_url": "https://github.com/user/repo/releases/tag/v2.1.0A6",
+ "latest_version": "v2.1.0A6" # 注意:需要完整的tag名称
}
# 下载并应用更新
@@ -132,7 +132,7 @@ def perform_update():
#### `download_update(tag_name: str) -> dict`
下载并应用更新。
-- `tag_name`: 版本标签名称(如v2.0.0)
+- `tag_name`: 版本标签名称(如v2.1.0A6)
返回字典结构:
```python
diff --git a/update/download_update.py b/update/download_update.py
index 42c9552..78c1000 100644
--- a/update/download_update.py
+++ b/update/download_update.py
@@ -47,7 +47,7 @@ def _extract_download_url(self, tag_name: str) -> Optional[str]:
Get the actual download URL from GitHub API
Args:
- tag_name: Version tag name (e.g., v2.0.0)
+ tag_name: Version tag name (e.g., v2.1.0A6)
Returns:
str: Actual zip file download URL, returns None if extraction fails
@@ -64,13 +64,13 @@ def _extract_download_url(self, tag_name: str) -> Optional[str]:
platform_str = "intel"
# Use GitHub API to get release information
- api_url = f"https://api.github.com/repos/pyquick/Converter/releases/tags/{tag_name}"
+ api_url = f"https://api.github.com/repos/intsant/Converter/releases/tags/{tag_name}"
response = requests.get(api_url, timeout=10)
response.encoding = 'utf-8'
if response.status_code != 200:
print(f"GitHub API request failed: {api_url} (status code: {response.status_code})")
# Fallback to manually constructed URL
- download_url = f"https://github.com/pyquick/Converter/releases/download/{tag_name}/Converter_{platform_str}_darwin.zip"
+ download_url = f"https://github.com/intsant/Converter/releases/download/{tag_name}/Converter_{platform_str}_darwin.zip"
return download_url
release_data = response.json()
@@ -87,7 +87,7 @@ def _extract_download_url(self, tag_name: str) -> Optional[str]:
# If no matching file is found, fallback to manually constructed URL
print(f"No matching file found {expected_filename}, using manually constructed URL")
- download_url = f"https://github.com/pyquick/Converter/releases/download/{tag_name}/Converter_{platform_str}_darwin.zip"
+ download_url = f"https://github.com/intsant/Converter/releases/download/{tag_name}/Converter_{platform_str}_darwin.zip"
# Verify if URL is valid - use GET request as HEAD requests might be handled differently by CDN
try:
@@ -113,7 +113,7 @@ def _extract_download_url(self, tag_name: str) -> Optional[str]:
else:
platform_str = "intel"
- download_url = f"https://github.com/pyquick/Converter/releases/download/{tag_name}/Converter_{platform_str}_darwin.zip"
+ download_url = f"https://github.com/intsant/Converter/releases/download/{tag_name}/Converter_{platform_str}_darwin.zip"
return download_url
def download_update(self, tag_name: str, progress_callback=None) -> Dict[str, Any]:
@@ -121,7 +121,7 @@ def download_update(self, tag_name: str, progress_callback=None) -> Dict[str, An
Download and extract update files
Args:
- tag_name: Version tag name (e.g., v2.0.0)
+ tag_name: Version tag name (e.g., v2.1.0A6)
progress_callback: Progress callback function
Returns:
@@ -690,6 +690,9 @@ def download_and_apply_update(update_info: Dict[str, Any], progress_callback=Non
# Create target directory for update
target_directory = tempfile.mkdtemp(prefix="converter_update_")
downloader = UpdateDownloader(download_url, target_directory)
+ created_downloader = True
+ else:
+ created_downloader = False
try:
result = downloader.download_update(latest_version, progress_callback)
@@ -717,14 +720,19 @@ def download_and_apply_update(update_info: Dict[str, Any], progress_callback=Non
print(f"❌ Failed to start update process: {e}")
result["status"] = "error"
result["message"] = f"Failed to start update process: {e}"
+ # Clean up if we created the downloader and update failed
+ if created_downloader:
+ downloader.cleanup()
return result
# Add downloader object to result for cleanup by caller when appropriate
result["downloader"] = downloader
+ result["created_downloader"] = created_downloader
return result
except Exception as e:
# Clean up immediately if an exception occurs
- downloader.cleanup()
+ if created_downloader:
+ downloader.cleanup()
return {
"status": "error",
"message": f"Error occurred during download: {e}"
@@ -734,8 +742,8 @@ def download_and_apply_update(update_info: Dict[str, Any], progress_callback=Non
if __name__ == "__main__":
# Test code
test_info = {
- "download_url": "https://github.com/pyquick/converter/releases/tag/v2.0.0",
- "latest_version": "2.0.0"
+ "download_url": "https://github.com/intsant/converter/releases/tag/v2.1.0A6",
+ "latest_version": "2.1.0A6"
}
result = download_and_apply_update(test_info, "./test_update")
diff --git a/update/example_usage.py b/update/example_usage.py
index 51d985d..ffacd4f 100644
--- a/update/example_usage.py
+++ b/update/example_usage.py
@@ -20,7 +20,7 @@ def example_update_workflow():
print("=== 应用程序更新工作流程示例 ===")
# 1. 初始化更新管理器
- current_version = "2.0.0RC1" # 从配置文件或代码中获取当前版本
+ current_version = "2.1.0A6" # 从配置文件或代码中获取当前版本
manager = UpdateManager(current_version)
print(f"当前应用程序版本: {current_version}")
@@ -82,8 +82,8 @@ def quick_download_example():
test_update_info = {
"status": "update_available",
"message": "测试更新",
- "download_url": "https://github.com/pyquick/converter/releases/tag/v2.0.0",
- "latest_version": "v2.0.0" # 注意:这里需要完整的tag名称
+ "download_url": "https://github.com/intsant/converter/releases/tag/v2.1.0A6",
+ "latest_version": "v2.1.0A6" # 注意:这里需要完整的tag名称
}
print("开始测试下载功能...")
diff --git a/update/run_complete_update.py b/update/run_complete_update.py
index 17108cb..91c79e9 100755
--- a/update/run_complete_update.py
+++ b/update/run_complete_update.py
@@ -19,7 +19,7 @@ def main():
print("🔄 开始检查更新...")
# 获取当前版本
- current_version = "2.0.0RC1" # 从settings/update_settings_gui.py获取
+ current_version = "2.1.0A6" # 从settings/update_settings_gui.py获取
# 创建更新管理器
update_manager = UpdateManager(current_version)
diff --git a/update/test_update.py b/update/test_update.py
index b6e7d78..e3fb959 100644
--- a/update/test_update.py
+++ b/update/test_update.py
@@ -19,7 +19,7 @@ def main():
print("✅ 成功导入UpdateManager")
# 获取当前版本
- current_version = "2.0.0RC1"
+ current_version = "2.1.0A6"
print(f"📍 当前版本: {current_version}")
# 创建更新管理器
diff --git a/update/update_complete.command b/update/update_complete.command
index 63bd197..6328243 100755
--- a/update/update_complete.command
+++ b/update/update_complete.command
@@ -32,7 +32,7 @@ pkill -f "python.*converter" 2>/dev/null || true
# Kill Converter.app processes
echo "🛑 Terminating Converter.app processes..."
pkill -f "Converter.app" 2>/dev/null || true
-pkill -f "com.pyquick.converter" 2>/dev/null || true
+pkill -f "com.intsant.converter" 2>/dev/null || true
# Wait for processes to fully terminate
echo "⏳ Waiting for processes to terminate..."
@@ -44,7 +44,7 @@ pkill -9 -f "python.*arc_gui.py" 2>/dev/null || true
pkill -9 -f "python.*Converter.py" 2>/dev/null || true
pkill -9 -f "python.*converter" 2>/dev/null || true
pkill -9 -f "Converter.app" 2>/dev/null || true
-pkill -9 -f "com.pyquick.converter" 2>/dev/null || true
+pkill -9 -f "com.intsant.converter" 2>/dev/null || true
# Wait again
sleep 2
diff --git a/update/update_manager.py b/update/update_manager.py
index 760456d..d3a7918 100644
--- a/update/update_manager.py
+++ b/update/update_manager.py
@@ -10,7 +10,7 @@ def __init__(self, current_version: str):
self.current_version = self._parse_version(current_version)
def _parse_version(self, version_str: str) -> tuple:
- # Handles versions like 2.0.0 and pre-release like 2.0.0RC1, 2.0.0A1, 2.0.0D1, 2.0.0RC1
+ # Handles versions like 2.1.0A6 and pre-release like 2.1.0A6RC1, 2.1.0A6A1, 2.1.0A6D1, 2.1.0A6RC1
parts = version_str.split('.')
if len(parts) != 3:
raise ValueError(f"Invalid version string: {version_str}")
@@ -46,13 +46,13 @@ def _parse_version(self, version_str: str) -> tuple:
def check_for_updates(self, include_prerelease: bool, prerelease_type: Optional[str] = None) -> dict:
try:
from con import CON
- repo_owner = "pyquick"
+ repo_owner = "intsant"
repo_name = "converter"
url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases"
headers = CON.headers.copy()
# 读取PAT设置
- settings = QSettings("pyquick", "converter")
+ settings = QSettings("intsant", "converter")
encrypted_pat = settings.value("general/github_pat", "", type=str)
# 如果有PAT,添加到headers
@@ -227,7 +227,7 @@ def get_github_pat(self) -> str:
str: 解密后的PAT,如果没有设置则返回空字符串
"""
try:
- settings = QSettings("pyquick", "converter")
+ settings = QSettings("intsant", "converter")
encrypted_pat = settings.value("general/github_pat", "", type=str)
if encrypted_pat:
import os, sys
diff --git a/update_complete.sh b/update_complete.sh
index d38f048..75a1952 100644
--- a/update_complete.sh
+++ b/update_complete.sh
@@ -39,7 +39,7 @@ try:
print('🔄 Starting to check for updates...')
# Get current version
- current_version = '2.0.0RC1'
+ current_version = '2.1.0A6'
print(f'📍 Current version: {current_version}')
# Create update manager