diff --git a/src/gui/ALStatusLabel.py b/src/gui/ALStatusLabel.py new file mode 100644 index 0000000..7b01648 --- /dev/null +++ b/src/gui/ALStatusLabel.py @@ -0,0 +1,246 @@ + +from enum import Enum + +from PySide6.QtWidgets import ( + QLabel +) +from PySide6.QtCore import ( + Qt, Property, QPropertyAnimation, QEasingCurve +) +from PySide6.QtGui import ( + QPainter, QColor, QConicalGradient, QPalette +) + + +class ALStatusLabel(QLabel): + + class Status(Enum): + """ + Enum class for representing the status of ALStatusLabel. + """ + + WAITING = 0 + RUNNING = 1 + SUCCESS = 2 + WARNING = 3 + FAILURE = 4 + + def __init__( + self, + parent = None + ): + + super().__init__(parent) + self.__status = self.Status.WAITING + self.__icon_angle = 0 + + self.setupUi() + + + def setupUi( + self + ): + + self.setFixedSize(36, 36) + self.setAlignment(Qt.AlignmentFlag.AlignCenter) + + self.RunningAnimation = QPropertyAnimation(self, b"iconAngle") + self.RunningAnimation.setDuration(1000) + self.RunningAnimation.setStartValue(0) + self.RunningAnimation.setEndValue(-360) + self.RunningAnimation.setLoopCount(-1) + self.RunningAnimation.setEasingCurve(QEasingCurve.Type.Linear) + + + def isDarkMode( + self + ) -> bool: + + return self.palette().color(QPalette.ColorRole.Window).value() < 128 + + + def getMarkColor( + self + ) -> QColor: + + return QColor("#FFFFFF") if self.isDarkMode() else QColor("#454545") + + @Property(Status) + def status( + self + ) -> Status: + + return self.__status + + @Property(int) + def iconAngle( + self + ) -> int: + + return self.__icon_angle + + @status.setter + def status( + self, + status: Status + ): + + if status not in self.Status: + raise ValueError(f"Invalid (class)Status[enum.Enum] value: {status}") + self.__status = status + if self.__status == self.Status.RUNNING: + self.RunningAnimation.start() + else: + self.RunningAnimation.stop() + self.update() + + @iconAngle.setter + def iconAngle( + self, + value: int + ): + + self.__icon_angle = value + self.update() + + + def paintEvent( + self, + event + ): + + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + center_x = self.width()/2 + center_y = self.height()/2 + radius = min(center_x, center_y) - 3 + match self.__status: + case self.Status.WAITING: + pen = painter.pen() + pen.setWidth(2) + pen.setBrush(Qt.BrushStyle.NoBrush) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + pen.setColor(QColor("#969696")) # grey + painter.setPen(pen) + painter.drawEllipse( + int(center_x - radius), + int(center_y - radius), + int(radius*2), + int(radius*2) + ) + case self.Status.RUNNING: + gradient = QConicalGradient(center_x, center_y, self.__icon_angle) + gradient.setColorAt(0.0, QColor("#2294FF" if self.isDarkMode() else "#0094FF")) + gradient.setColorAt(1.0, QColor("#2294FF00")) + pen = painter.pen() + pen.setWidth(3) + pen.setBrush(gradient) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + painter.setPen(pen) + painter.drawEllipse( + int(center_x - radius), + int(center_y - radius), + int(radius*2), + int(radius*2) + ) + case self.Status.SUCCESS: + # draw the success green circle + pen = painter.pen() + pen.setWidth(2) + pen.setBrush(Qt.BrushStyle.NoBrush) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + pen.setColor(QColor("#4CAF50" if self.isDarkMode() else "#00AF50")) # green + painter.setPen(pen) + painter.drawEllipse( + int(center_x - radius), + int(center_y - radius), + int(radius*2), + int(radius*2) + ) + # draw the success check mark '✓' + painter.setPen(Qt.PenStyle.SolidLine) + pen = painter.pen() + pen.setWidth(3) + pen.setBrush(Qt.BrushStyle.NoBrush) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + # white when dark mode, black when light mode + pen.setColor(self.getMarkColor()) + painter.setPen(pen) + mark_size = radius/2 + mark_path = [ + (center_x - mark_size, center_y), + (center_x - mark_size/3, center_y + mark_size/2), + (center_x + mark_size, center_y - mark_size/2) + ] + painter.drawLine( + int(mark_path[0][0]),int(mark_path[0][1]), + int(mark_path[1][0]),int(mark_path[1][1]) + ) + painter.drawLine( + int(mark_path[1][0]),int(mark_path[1][1]), + int(mark_path[2][0]),int(mark_path[2][1]) + ) + case self.Status.WARNING: + # draw the warning orange circle + pen = painter.pen() + pen.setWidth(2) + pen.setBrush(Qt.BrushStyle.NoBrush) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + pen.setColor(QColor("#FF9800")) # orange + painter.setPen(pen) + painter.drawEllipse( + int(center_x - radius), + int(center_y - radius), + int(radius*2), + int(radius*2) + ) + # draw the warning exclamation mark '!' + painter.setPen(Qt.PenStyle.SolidLine) + pen = painter.pen() + pen.setWidth(3) + pen.setBrush(Qt.BrushStyle.NoBrush) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + # white when dark mode, black when light mode + pen.setColor(self.getMarkColor()) + painter.setPen(pen) + painter.drawLine( + int(center_x), int(center_y - radius/2), + int(center_x), int(center_y + radius/6) + ) + painter.drawPoint( + int(center_x), int(center_y + radius/2) + ) + case self.Status.FAILURE: + # draw the failure red circle + pen = painter.pen() + pen.setWidth(2) + pen.setBrush(Qt.BrushStyle.NoBrush) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + pen.setColor(QColor("#DC0000")) # red + painter.setPen(pen) + painter.drawEllipse( + int(center_x - radius), + int(center_y - radius), + int(radius*2), + int(radius*2) + ) + # draw the failure cross mark '✗' + painter.setPen(Qt.PenStyle.SolidLine) + pen = painter.pen() + pen.setWidth(3) + pen.setBrush(Qt.BrushStyle.NoBrush) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + # white when dark mode, black when light mode + pen.setColor(self.getMarkColor()) + painter.setPen(pen) + mark_size = radius/3 + painter.drawLine( + int(center_x - mark_size), int(center_y - mark_size), + int(center_x + mark_size), int(center_y + mark_size) + ) + painter.drawLine( + int(center_x + mark_size), int(center_y - mark_size), + int(center_x - mark_size), int(center_y + mark_size) + ) + painter.end() + super().paintEvent(event) diff --git a/src/gui/ALWebDriverDownloadDialog.py b/src/gui/ALWebDriverDownloadDialog.py new file mode 100644 index 0000000..6f34702 --- /dev/null +++ b/src/gui/ALWebDriverDownloadDialog.py @@ -0,0 +1,518 @@ +# -*- coding: utf-8 -*- +""" +Copyright (c) 2026 KenanZhu. +All rights reserved. + +This software is provided "as is", without any warranty of any kind. +You may use, modify, and distribute this file under the terms of the MIT License. +See the LICENSE file for details. +""" +import threading +from typing import Optional + +from PySide6.QtCore import ( + Qt, Slot, QThread, Signal +) +from PySide6.QtWidgets import ( + QDialog, QLabel, QComboBox, QProgressBar, + QPushButton, QVBoxLayout, QHBoxLayout, + QMessageBox, QFrame, QLineEdit +) +from PySide6.QtGui import ( + QCloseEvent +) + +from managers.driver.WebDriverManager import ( + instance as webdriver_manager_instance, + WebDriverManager, WebDriverInfo, WebDriverType +) +from gui.ALStatusLabel import ALStatusLabel + + +class DownloadWorker(QThread): + """ + Worker thread for downloading web drivers. + """ + + progress = Signal(float, int, float, str) + finished = Signal(object, str) + error = Signal(str) + cancelled = Signal() + + def __init__( + self, + driver_manager: WebDriverManager, + driver_info: WebDriverInfo + ): + super().__init__() + self.__driver_manager = driver_manager + self.__driver_info = driver_info + self.__driver_path = None + self.__cancelled = False + self.__cancel_event = threading.Event() + + def cancel( + self + ): + """ + Cancel the download operation. + """ + + self.__cancelled = True + self.__cancel_event.set() + + def run( + self + ): + try: + if self.__cancelled: + self.cancelled.emit() + return + self.__driver_path = self.__driver_manager.installDriver( + self.__driver_info, + progress_callback=self.onProgress, + cancel_event=self.__cancel_event + ) + if self.__cancelled: + self.cancelled.emit() + return + if self.__driver_path: + self.finished.emit(self.__driver_path, "") + else: + self.error.emit("下载失败: 未返回有效路径") + except Exception as e: + if not self.__cancelled: + self.error.emit(f"下载失败: {str(e)}") + + def onProgress( + self, + downloaded: float, + total: int, + speed: float, + message: str + ): + + if self.__cancel_event.is_set(): + self.__cancelled = True + if not self.__cancelled: + self.progress.emit(downloaded, total, speed, message) + + +class ALWebDriverDownloadDialog(QDialog): + + def __init__( + self, + parent: Optional[QDialog] = None, + driver_dir: str = "" + ): + """ + Web driver download dialog. + + Args: + parent: Parent widget. + driver_dir: Driver directory path. + """ + + super().__init__(parent) + + self.__driver_dir = driver_dir + self.__driver_manager: Optional[WebDriverManager] = None + self.__confirmed = False + self.__selected_driver_info: Optional[WebDriverInfo] = None + self.__driver_infos: list[WebDriverInfo] = [] + self.__download_thread: Optional[DownloadWorker] = None + + self.setupUi() + self.connectSignals() + self.initializeDriverManager() + self.refreshDriverList() + + def showEvent( + self, + event + ): + + result = super().showEvent(event) + if self.parent(): + screen_rect = self.screen().geometry() + target_pos = self.parent().geometry().center() + target_pos.setX(target_pos.x() - self.width()//2) + target_pos.setY(target_pos.y() - self.height()//2) + if target_pos.x() < 0: + target_pos.setX(0) + if target_pos.x() + self.width() > screen_rect.width(): + target_pos.setX(screen_rect.width() - self.width()) + if target_pos.y() < 0: + target_pos.setY(0) + if target_pos.y() + self.height() > screen_rect.height(): + target_pos.setY(screen_rect.height() - self.height()) + self.move(target_pos) + return result + + def setupUi( + self + ): + + self.setModal(True) + self.setMaximumHeight(240) + self.setMinimumHeight(240) + self.setWindowTitle("浏览器驱动下载 - AutoLibrary") + + self.MainLayout = QVBoxLayout(self) + self.MainLayout.setContentsMargins(5, 5, 5, 5) + self.MainLayout.setSpacing(5) + + self.BrowserCountLabel = QLabel("检测到 0 个可用浏览器:") + self.MainLayout.addWidget(self.BrowserCountLabel) + + self.DriverInfoLayout = QHBoxLayout() + self.DriverInfoLayout.setSpacing(5) + self.DriverComboBox = QComboBox() + self.DriverInfoLayout.addWidget(self.DriverComboBox) + self.StatusLabel = ALStatusLabel() + self.StatusLabel.setFixedSize(32, 32) + self.DriverInfoLayout.addWidget(self.StatusLabel) + self.MainLayout.addLayout(self.DriverInfoLayout) + + self.DetailLayout = QVBoxLayout() + self.DetailLayout.setSpacing(5) + self.DetailLayout.setContentsMargins(5, 5, 5, 5) + self.BrowserTypeLabel = QLabel("类型:") + self.DetailLayout.addWidget(self.BrowserTypeLabel) + self.VersionLabel = QLabel("版本:") + self.DetailLayout.addWidget(self.VersionLabel) + self.PathLabel = QLineEdit() + self.PathLabel.setReadOnly(True) + self.PathLabel.setText("路径:未安装") + self.DetailLayout.addWidget(self.PathLabel) + self.MainLayout.addLayout(self.DetailLayout) + + self.Line = QFrame() + self.Line.setFrameShape(QFrame.Shape.HLine) + self.Line.setFrameShadow(QFrame.Shadow.Sunken) + self.MainLayout.addWidget(self.Line) + self.ProgressBar = QProgressBar() + self.ProgressBar.setValue(0) + self.ProgressBar.setTextVisible(False) + self.MainLayout.addWidget(self.ProgressBar) + self.ProgressText = QLabel("") + self.ProgressText.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.MainLayout.addWidget(self.ProgressText) + self.ControlLayout = QHBoxLayout() + self.ControlLayout.setSpacing(8) + self.ControlLayout.setAlignment(Qt.AlignmentFlag.AlignRight) + self.RefreshButton = QPushButton("刷新") + self.RefreshButton.setFixedSize(80, 25) + self.DownloadButton = QPushButton("下载驱动") + self.DownloadButton.setFixedSize(80, 25) + self.DeleteButton = QPushButton("删除驱动") + self.DeleteButton.setFixedSize(80, 25) + self.CancelButton = QPushButton("取消") + self.CancelButton.setFixedSize(80, 25) + self.ConfirmButton = QPushButton("确认") + self.ConfirmButton.setFixedSize(80, 25) + self.ConfirmButton.setEnabled(False) + + self.ControlLayout.addWidget(self.RefreshButton) + self.ControlLayout.addWidget(self.DownloadButton) + self.ControlLayout.addWidget(self.DeleteButton) + self.ControlLayout.addWidget(self.CancelButton) + self.ControlLayout.addWidget(self.ConfirmButton) + self.MainLayout.addLayout(self.ControlLayout) + + + def connectSignals( + self + ): + + self.RefreshButton.clicked.connect(self.onRefreshButtonClicked) + self.DownloadButton.clicked.connect(self.onDownloadButtonClicked) + self.DeleteButton.clicked.connect(self.onDeleteButtonClicked) + self.CancelButton.clicked.connect(self.onCancelButtonClicked) + self.ConfirmButton.clicked.connect(self.onConfirmButtonClicked) + self.DriverComboBox.currentIndexChanged.connect(self.onDriverComboBoxChanged) + + + def initializeDriverManager( + self + ): + + try: + self.__driver_manager = webdriver_manager_instance(self.__driver_dir) + except ValueError as e: + QMessageBox.warning(self, "初始化失败", f"WebDriverManager 初始化失败:\n{str(e)}") + self.reject() + + + def refreshDriverList( + self + ): + + if not self.__driver_manager: + return + self.__driver_manager.refresh() + self.__driver_infos = self.__driver_manager.getDriverInfos() + self.DriverComboBox.clear() + for driver_info in self.__driver_infos: + display_text = f"{driver_info.driver_type.value} - {driver_info.browser_version}" + self.DriverComboBox.addItem(display_text) + count = len(self.__driver_infos) + self.BrowserCountLabel.setText(f"检测到 {count} 个可用浏览器:") + if self.__driver_infos: + self.onDriverComboBoxChanged(0) + + + def onDriverComboBoxChanged( + self, + index: int + ): + + if not self.__driver_infos or index < 0 or index >= len(self.__driver_infos): + return + driver_info = self.__driver_infos[index] + self.updateDriverInfoDisplay(driver_info) + self.updateButtonStates(driver_info) + + + @Slot() + def onRefreshButtonClicked( + self + ): + + self.refreshDriverList() + + + @Slot() + def onDeleteButtonClicked( + self + ): + + index = self.DriverComboBox.currentIndex() + if index < 0 or index >= len(self.__driver_infos): + return + driver_info = self.__driver_infos[index] + if driver_info.driver_status.name != "INSTALLED": + QMessageBox.information(self, "提示 - AutoLibrary", "该驱动未安装, 无需删除") + return + reply = QMessageBox.question( + self, + "确认删除 - AutoLibrary", + f"确定要删除 {driver_info.driver_type.value} 驱动吗 ?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + if reply == QMessageBox.StandardButton.No: + return + try: + self.__driver_manager.uninstallDriver(driver_info) + self.refreshDriverList() + QMessageBox.information(self, "删除成功 - AutoLibrary", "驱动已成功删除") + except Exception as e: + QMessageBox.critical(self, "删除失败 - AutoLibrary", f"删除驱动时出错:\n{str(e)}") + + @Slot() + def onDownloadButtonClicked( + self + ): + + index = self.DriverComboBox.currentIndex() + if index < 0 or index >= len(self.__driver_infos): + return + driver_info = self.__driver_infos[index] + if driver_info.driver_status.name == "INSTALLED": + return + self.StatusLabel.status = ALStatusLabel.Status.RUNNING + self.DownloadButton.setEnabled(False) + self.RefreshButton.setEnabled(False) + self.DriverComboBox.setEnabled(False) + self.ProgressBar.setValue(0) + self.ProgressText.setText("正在下载驱动...") + self.__download_thread = DownloadWorker(self.__driver_manager, driver_info) + self.__download_thread.progress.connect(self.onDownloadProgress) + self.__download_thread.finished.connect(self.onDownloadFinished) + self.__download_thread.error.connect(self.onDownloadError) + self.__download_thread.cancelled.connect(self.onDownloadCancelled) + self.__download_thread.start() + + @Slot() + def onDownloadProgress( + self, + downloaded: float, + total: int, + speed: float, + message: str + ): + + progress = downloaded + self.ProgressBar.setValue(progress) + if speed >= 1024: + speed_text = f"{speed/1024:.1f} MB/s" + else: + speed_text = f"{speed:.1f} KB/s" + progress_text = f"{message}... {downloaded:.1f}% - {speed_text}" + self.ProgressText.setText(progress_text) + + @Slot() + def onDownloadFinished( + self + ): + + self.ProgressBar.setValue(100) + self.ProgressText.setText("下载完成 !") + self.StatusLabel.status = ALStatusLabel.Status.SUCCESS + index = self.DriverComboBox.currentIndex() + if 0 <= index < len(self.__driver_infos): + driver_info = self.__driver_infos[index] + self.updateDriverInfoDisplay(driver_info) + self.__download_thread = None + self.ConfirmButton.setEnabled(True) + self.DownloadButton.setEnabled(False) + self.RefreshButton.setEnabled(True) + self.DriverComboBox.setEnabled(True) + self.DeleteButton.setEnabled(True) + + @Slot() + def onDownloadError( + self, + error_message: str + ): + + self.StatusLabel.status = ALStatusLabel.Status.FAILURE + QMessageBox.critical(self, "下载失败 - AutoLibrary", error_message) + self.DownloadButton.setEnabled(True) + self.RefreshButton.setEnabled(True) + self.DriverComboBox.setEnabled(True) + self.CancelButton.setEnabled(True) + + + @Slot() + def onDownloadCancelled( + self + ): + + if self.__download_thread: + self.__download_thread.wait(3000) + self.__download_thread = None + index = self.DriverComboBox.currentIndex() + if 0 <= index < len(self.__driver_infos): + driver_info = self.__driver_infos[index] + self.__driver_manager.cancelDriverDownload(driver_info) + self.updateDriverInfoDisplay(driver_info) + self.ProgressText.setText("下载已取消") + self.ProgressBar.setValue(0) + self.StatusLabel.status = ALStatusLabel.Status.WAITING + self.DownloadButton.setEnabled(True) + self.RefreshButton.setEnabled(True) + self.DriverComboBox.setEnabled(True) + self.CancelButton.setEnabled(True) + self.DeleteButton.setEnabled(False) + + + @Slot() + def onConfirmButtonClicked( + self + ): + + index = self.DriverComboBox.currentIndex() + if index < 0 or index >= len(self.__driver_infos): + return + driver_info = self.__driver_infos[index] + if driver_info.driver_status.name != "INSTALLED": + return + self.__selected_driver_info = driver_info + self.__confirmed = True + self.accept() + + + @Slot() + def onCancelButtonClicked( + self + ): + + if self.__download_thread: + reply = QMessageBox.question( + self, + "确认取消 - AutoLibrary", + "正在下载中, 确定要取消下载吗 ?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + if reply == QMessageBox.StandardButton.Yes: + self.__download_thread.cancel() + else: + self.__confirmed = False + self.__selected_driver_info = None + self.reject() + + + def closeEvent( + self, + event: QCloseEvent + ): + + if self.__download_thread and self.__download_thread.isRunning(): + reply = QMessageBox.question( + self, + "确认关闭 - AutoLibrary", + "驱动正在下载中, 确定要取消并关闭对话框吗 ?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + if reply == QMessageBox.StandardButton.No: + event.ignore() + return + self.__download_thread.cancel() + self.__download_thread.wait(5000) + if not self.__confirmed: + self.__selected_driver_info = None + event.accept() + super().closeEvent(event) + + + def getSelectedDriverInfo( + self + ) -> Optional[WebDriverInfo]: + + return self.__selected_driver_info + + + def updateDriverInfoDisplay( + self, + driver_info: WebDriverInfo + ): + + if driver_info.driver_type == WebDriverType.CHROME: + driver_type = "Google Chrome" + elif driver_info.driver_type == WebDriverType.FIREFOX: + driver_type = "Mozilla Firefox" + elif driver_info.driver_type == WebDriverType.EDGE: + driver_type = "Microsoft Edge" + else: + driver_type = "未知" + self.BrowserTypeLabel.setText(f"类型:{driver_type}") + self.VersionLabel.setText(f"版本:{driver_info.driver_version}") + if driver_info.driver_path: + self.PathLabel.setText(str(driver_info.driver_path)) + else: + self.PathLabel.setText("未安装") + match driver_info.driver_status.name: + case "NOT_INSTALLED": + self.StatusLabel.status = ALStatusLabel.Status.WAITING + case "INSTALLED": + self.StatusLabel.status = ALStatusLabel.Status.SUCCESS + case "DOWNLOADING": + self.StatusLabel.status = ALStatusLabel.Status.RUNNING + case "ERROR": + self.StatusLabel.status = ALStatusLabel.Status.FAILURE + + + def updateButtonStates( + self, + driver_info: WebDriverInfo + ): + + if driver_info.driver_status.name == "INSTALLED": + self.DownloadButton.setEnabled(False) + self.DeleteButton.setEnabled(True) + self.ConfirmButton.setEnabled(True) + else: + self.DeleteButton.setEnabled(False) + self.DownloadButton.setEnabled(True) + self.ConfirmButton.setEnabled(False)