From 1ccace3e8b7560ea8e42c030624ca368785b4c64 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 12:04:41 +0000 Subject: [PATCH 1/3] Initial plan From 1233f9f7d5f3d6ce1e6ffd4e1c9405162f245f06 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 12:15:41 +0000 Subject: [PATCH 2/3] Refactor: split main.py into core/, widgets/, ui/ packages Co-authored-by: UltraAce258 <174670272+UltraAce258@users.noreply.github.com> --- core/__init__.py | 1 + core/executor.py | 148 ++++ core/i18n.py | 36 + core/script_registry.py | 222 +++++ core/utils.py | 25 + main.py | 1659 ++----------------------------------- ui/__init__.py | 1 + ui/main_window.py | 1117 +++++++++++++++++++++++++ widgets/__init__.py | 1 + widgets/dynamic_params.py | 192 +++++ widgets/terminal.py | 183 ++++ 11 files changed, 1987 insertions(+), 1598 deletions(-) create mode 100644 core/__init__.py create mode 100644 core/executor.py create mode 100644 core/i18n.py create mode 100644 core/script_registry.py create mode 100644 core/utils.py create mode 100644 ui/__init__.py create mode 100644 ui/main_window.py create mode 100644 widgets/__init__.py create mode 100644 widgets/dynamic_params.py create mode 100644 widgets/terminal.py diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..97daee7 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1 @@ +# core package diff --git a/core/executor.py b/core/executor.py new file mode 100644 index 0000000..1572e28 --- /dev/null +++ b/core/executor.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Script executor – runs a Python script in a background thread via QProcess. +脚本执行器 – 通过 QProcess 在后台线程中运行 Python 脚本。 + +Emitted signals +--------------- +output_updated(str) – a decoded line of stdout/stderr output +progress_updated(float, float, str) – parsed [PROGRESS] line values +process_finished(int) – process exit code +""" + +import os +import platform +import shlex +import sys + +from PyQt6.QtCore import QProcess, QThread, pyqtSignal + + +class ScriptExecutor(QThread): + """Executes a script in a separate thread to keep the GUI responsive. + + 在独立线程中执行脚本,以保持 GUI 响应性。 + """ + + # Signal carrying a decoded output line / 携带已解码输出行的信号 + output_updated = pyqtSignal(str) + # Signal carrying (current, maximum, description) progress values / 携带进度值的信号 + progress_updated = pyqtSignal(float, float, str) + # Signal carrying the process exit code / 携带进程退出码的信号 + process_finished = pyqtSignal(int) + + def __init__(self, command: list, working_dir: str | None = None) -> None: + """ + :param command: List of command arguments (script path + args). + 命令参数列表(脚本路径 + 参数)。 + :param working_dir: Working directory for the subprocess. + 子进程的工作目录。 + """ + super().__init__() + self.command = command + self.working_dir = working_dir + self.process: QProcess | None = None + # Choose encoding based on OS to handle console output correctly. + # 根据操作系统选择编码,以正确处理控制台输出。 + self.output_encoding = "gbk" if platform.system() == "Windows" else "utf-8" + + # ------------------------------------------------------------------ + # QThread interface + # ------------------------------------------------------------------ + + def run(self) -> None: + """Main thread logic – starts QProcess and waits for it to finish. + + 线程主逻辑 – 启动 QProcess 并等待其结束。 + """ + try: + display_command = shlex.join( + [os.path.basename(sys.executable)] + self.command + ) + self.output_updated.emit(f"Executing command: {display_command}\n") + + self.process = QProcess() + # Merge stdout and stderr so all output arrives on one channel. + # 合并 stdout 与 stderr,使所有输出经同一通道传递。 + self.process.setProcessChannelMode( + QProcess.ProcessChannelMode.MergedChannels + ) + if self.working_dir: + self.process.setWorkingDirectory(self.working_dir) + + self.process.readyReadStandardOutput.connect(self._handle_output) + self.process.finished.connect(self.process_finished.emit) + + self.process.start(sys.executable, self.command) + self.process.waitForFinished(-1) # -1 = wait indefinitely / 无限等待 + except Exception as exc: + self.output_updated.emit(f"Error executing command: {exc}\n") + self.process_finished.emit(1) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _handle_output(self) -> None: + """Read buffered output, decode it, and route each line. + + Parses ``[PROGRESS] current/max | description`` lines and emits + *progress_updated*; all other lines go to *output_updated*. + + 读取缓冲输出、解码,并逐行路由。 + 解析 [PROGRESS] 行并发出 progress_updated;其余行发出 output_updated。 + """ + if not self.process: + return + data: bytes = self.process.readAllStandardOutput().data() + try: + decoded_text = data.decode("utf-8") + except UnicodeDecodeError: + decoded_text = data.decode(self.output_encoding, errors="replace") + + for line in decoded_text.strip().split("\n"): + if not line.strip(): + continue + if line.startswith("[PROGRESS]"): + try: + payload = line[len("[PROGRESS]"):].strip() + progress_part, description = payload.split("|", 1) + current_str, max_str = progress_part.split("/", 1) + current = float(current_str.strip()) + maximum = float(max_str.strip()) + self.progress_updated.emit(current, maximum, description.strip()) + except (ValueError, IndexError) as exc: + self.output_updated.emit( + f"Invalid progress format: {line}\nError: {exc}\n" + ) + else: + self.output_updated.emit(line + "\n") + + # ------------------------------------------------------------------ + # Public control methods + # ------------------------------------------------------------------ + + def send_input(self, text: str) -> None: + """Write *text* followed by a newline to the running script's stdin. + + 向正在运行的脚本的 stdin 写入 *text* 及换行符。 + """ + if ( + self.process + and self.process.state() == QProcess.ProcessState.Running + ): + self.process.write(f"{text}\n".encode("utf-8")) + + def terminate(self) -> None: + """Gracefully stop the running process; kill it after a 3-second timeout. + + 优雅地停止正在运行的进程;3 秒超时后强制结束。 + """ + if ( + self.process + and self.process.state() == QProcess.ProcessState.Running + ): + self.process.terminate() + if not self.process.waitForFinished(3000): + self.process.kill() diff --git a/core/i18n.py b/core/i18n.py new file mode 100644 index 0000000..e18f418 --- /dev/null +++ b/core/i18n.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Internationalisation helper. +Stores the UI text dictionary that is shared across all modules. +模块间共享的 UI 文字字典。 + +Usage +----- + # Load once at application startup: + from core.i18n import load_ui_texts, UI_TEXTS + load_ui_texts(json_path) + + # Then, in any module: + from core.i18n import UI_TEXTS + label = UI_TEXTS['zh']['run_button'] +""" + +import json + +# The single shared dict – populated by load_ui_texts() before the GUI starts. +# 单一共享字典 – 在 GUI 启动前由 load_ui_texts() 填充。 +UI_TEXTS: dict = {} + + +def load_ui_texts(json_path: str) -> None: + """Read *json_path* and populate the global UI_TEXTS dict in-place. + + Using ``dict.update`` keeps existing references valid, so modules that + imported ``UI_TEXTS`` before the call will see the new data. + + 读取 JSON 文件并就地更新全局 UI_TEXTS 字典。 + 由于使用 dict.update,之前已导入 UI_TEXTS 的模块也能看到新数据。 + """ + with open(json_path, "r", encoding="utf-8") as fh: + UI_TEXTS.update(json.load(fh)) diff --git a/core/script_registry.py b/core/script_registry.py new file mode 100644 index 0000000..3e6e72f --- /dev/null +++ b/core/script_registry.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Script registry – discovers scripts and parses their parameters. +脚本注册表 – 发现脚本并解析其参数。 + +Parameter-parsing strategy (backward-compatible) +------------------------------------------------- +1. Try ``python script.py --gui-schema``. If the script exits 0 and prints + valid JSON, use that schema directly. +2. Otherwise fall back to running ``python script.py --help`` and applying a + regex heuristic – exactly as the original code did. + +参数解析策略(向后兼容) +1. 尝试 ``python script.py --gui-schema``。若脚本以 0 退出并打印有效 JSON, + 则直接使用该 schema。 +2. 否则回退到运行 ``python script.py --help`` 并使用正则表达式启发式解析, + 与原代码完全一致。 + +Expected --gui-schema JSON format +---------------------------------- +[ + { + "name": "--sort", + "type": "choice", # "choice" | "value" | "flag" + "choices": ["asc", "desc"], + "default": "asc", + "help": "Sort order [display: asc=升序,Ascending | desc=降序,Descending]" + }, + ... +] +""" + +import glob +import json +import os +import re +import subprocess +import sys +from typing import Any + + +# ------------------------------------------------------------------ +# Docstring extraction +# ------------------------------------------------------------------ + +def extract_docstring(content: str) -> str: + """Return the module-level docstring from *content*, or an empty string. + + 从 *content* 中提取模块级文档字符串,若无则返回空字符串。 + """ + match = re.search( + r'^\s*("""(.*?)"""|\'\'\'(.*?)\'\'\')', + content, + re.DOTALL | re.MULTILINE, + ) + if match: + return (match.group(2) or match.group(3)).strip() + return "" + + +# ------------------------------------------------------------------ +# Script scanning +# ------------------------------------------------------------------ + +def scan(scripts_dir: str) -> list[dict[str, Any]]: + """Scan *scripts_dir* for ``*.py`` files and return a list of info dicts. + + Each dict has the keys: ``path``, ``name_zh``, ``name_en``. + + 扫描 *scripts_dir* 中的 ``*.py`` 文件,返回信息字典列表。 + 每个字典包含键:path、name_zh、name_en。 + """ + results: list[dict[str, Any]] = [] + if not os.path.exists(scripts_dir): + os.makedirs(scripts_dir) + return results + + for script_path in sorted(glob.glob(os.path.join(scripts_dir, "*.py"))): + try: + with open(script_path, "r", encoding="utf-8") as fh: + content = fh.read() + docstring = extract_docstring(content) + + zh_name_match = re.search(r"\[display-name-zh\](.*?)\n", docstring) + en_name_match = re.search(r"\[display-name-en\](.*?)\n", docstring) + + zh_name = ( + zh_name_match.group(1).strip() + if zh_name_match + else os.path.basename(script_path) + ) + en_name = ( + en_name_match.group(1).strip() + if en_name_match + else os.path.basename(script_path) + ) + + results.append({"path": script_path, "name_zh": zh_name, "name_en": en_name}) + except Exception as exc: # noqa: BLE001 + print(f"Error loading script {script_path}: {exc}") + + return results + + +# ------------------------------------------------------------------ +# Parameter parsing +# ------------------------------------------------------------------ + +def parse_params(script_path: str) -> list[dict[str, Any]]: + """Return a list of parameter dicts for *script_path*. + + Tries ``--gui-schema`` first; falls back to ``--help`` regex. + + 返回 *script_path* 的参数字典列表。 + 优先尝试 --gui-schema;失败则回退至 --help 正则解析。 + """ + params = _try_gui_schema(script_path) + if params is not None: + return params + return _parse_help_text(script_path) + + +# ------------------------------------------------------------------ +# Private helpers +# ------------------------------------------------------------------ + +def _try_gui_schema(script_path: str) -> list[dict[str, Any]] | None: + """Attempt to obtain parameters via ``--gui-schema``. + + Returns the parsed list on success, or *None* if the script does not + support the flag or returns invalid JSON. + + 尝试通过 --gui-schema 获取参数列表。 + 成功则返回解析后的列表;若脚本不支持该标志或返回无效 JSON,则返回 None。 + """ + try: + result = subprocess.run( + [sys.executable, script_path, "--gui-schema"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=10, + ) + if result.returncode != 0: + return None + schema = json.loads(result.stdout) + if not isinstance(schema, list): + return None + # Filter internal flags just in case. + # 过滤内部标志以防万一。 + return [p for p in schema if p.get("name") not in ("--gui-mode", "--lang")] + except Exception: # noqa: BLE001 + return None + + +def _parse_help_text(script_path: str) -> list[dict[str, Any]]: + """Parse parameters from ``python script.py --help`` output using regex. + + 通过正则表达式从 ``--help`` 输出中解析参数。 + This is the original heuristic, kept 100% intact for backward compatibility. + 这是原始的启发式方法,保持 100% 不变以确保向后兼容。 + """ + params: list[dict[str, Any]] = [] + try: + result = subprocess.run( + [sys.executable, script_path, "--help"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + help_text = result.stdout + + # Regex groups: + # 1 – optional short flag (e.g. '-v, ') + # 2 – long flag name (e.g. 'verbose') + # 3 – optional METAVAR (indicates a value is expected) + # 4 – optional choices (e.g. 'name,date') + # 5 – optional default + pattern = re.compile( + r"^\s+(-[a-zA-Z],\s+)?--([a-zA-Z0-9_-]+)\s*([A-Z_]+)?.*?" + r"(?:\{([^}]+)\})?.*?(?:\(default:\s*([^)]+)\))?", + re.MULTILINE | re.IGNORECASE, + ) + + for match in pattern.finditer(help_text): + name = f"--{match.group(2)}" + # Skip internal flags used by the launcher itself. + # 跳过启动器自身使用的内部标志。 + if name in ("--gui-mode", "--lang"): + continue + + metavar = match.group(3) + choices = match.group(4) + default_val = match.group(5) + + param_info: dict[str, Any] = {"name": name} + + if choices: + param_info["type"] = "choice" + param_info["choices"] = [c.strip() for c in choices.split(",")] + elif metavar: + param_info["type"] = "value" + else: + param_info["type"] = "flag" + + if default_val: + param_info["default"] = default_val.strip() + + # help text is not available from --help regex; leave as empty string. + # 正则解析无法获取 help 文本;置为空字符串。 + param_info["help"] = "" + + params.append(param_info) + + except Exception as exc: # noqa: BLE001 + print( + f"Could not parse parameters for {os.path.basename(script_path)}: {exc}" + ) + return params diff --git a/core/utils.py b/core/utils.py new file mode 100644 index 0000000..efb3206 --- /dev/null +++ b/core/utils.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Shared utility helpers. +共享工具函数。 +""" + +import os +import sys + + +def resource_path(relative_path: str) -> str: + """Return absolute path to *relative_path*, compatible with PyInstaller bundles. + + 返回相对路径对应的绝对路径,兼容 PyInstaller 打包环境。 + """ + try: + # PyInstaller stores the unpacked bundle in sys._MEIPASS. + # PyInstaller 将解压后的包存储在 sys._MEIPASS 中。 + base_path = sys._MEIPASS # type: ignore[attr-defined] + except AttributeError: + # In development the project root is the parent of this file's directory. + # 开发环境下,项目根目录是本文件所在目录的上一级。 + base_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + return os.path.join(base_path, relative_path) diff --git a/main.py b/main.py index ef953c8..2802ca1 100644 --- a/main.py +++ b/main.py @@ -1,1598 +1,61 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -import sys -import os -import re -import glob -import json -import shlex -import platform -import subprocess -import configparser -from datetime import datetime -from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, - QHBoxLayout, QListWidget, QTextEdit, QLabel, - QSplitter, QFileDialog, QPushButton, QMessageBox, QMenu, - QListWidgetItem, QTabWidget, QLineEdit, QCheckBox, - QFormLayout, QComboBox, QFrame, QGridLayout, QProgressBar) -from PyQt6.QtCore import Qt, QProcess, pyqtSignal, QThread -from PyQt6.QtGui import (QFont, QTextCursor, QKeyEvent, QAction, QKeySequence, - QPalette, QActionGroup, QColor) - -# Resource Path Function / 资源路径函数 -def resource_path(relative_path): - """ - Get absolute path to resource, works for dev and for PyInstaller. - 获取资源的绝对路径,无论是在开发环境还是在PyInstaller打包后都能正常工作。 - """ - try: - # PyInstaller creates a temp folder and stores path in _MEIPASS - # PyInstaller会创建一个临时文件夹,并将路径存储在_MEIPASS中 - base_path = sys._MEIPASS - except Exception: - # If not packaged, use the normal script directory - # 如果没有被打包,则使用常规的脚本目录 - base_path = os.path.abspath(os.path.dirname(__file__)) - return os.path.join(base_path, relative_path) - -# UI文本读取函数 / UI Text Loading Function -def load_ui_texts(json_path): - with open(json_path, "r", encoding="utf-8") as f: - return json.load(f) - -class ScriptExecutor(QThread): - """ - Executes a script in a separate thread to keep the GUI responsive. - 在一个独立的线程中执行脚本,以保持GUI的响应性。 - """ - # Signal to send script output back to the main thread / 将脚本输出发送回主线程的信号 - output_updated = pyqtSignal(str) - # Signal to notify the main thread when the process is updated / 进程更新时通知主线程的信号 - progress_updated = pyqtSignal(float, float, str) - # Signal to notify the main thread when the process is finished / 进程结束时通知主线程的信号 - process_finished = pyqtSignal(int) - - def __init__(self, command, working_dir=None): - """ - Initializes the executor with the command to run. - 使用要运行的命令初始化执行器。 - - :param command: A list of command arguments. / 命令参数列表。 - :param working_dir: The working directory for the script. / 脚本的工作目录。 - """ - super().__init__() - self.command = command - self.working_dir = working_dir - self.process = None - # Set default encoding based on OS to handle console output correctly - # 根据操作系统设置默认编码,以正确处理控制台输出 - self.output_encoding = 'gbk' if platform.system() == "Windows" else 'utf-8' - - def run(self): - """ - The main logic of the thread. Starts the QProcess. - 线程的主要逻辑。启动QProcess。 - """ - try: - display_command = shlex.join([os.path.basename(sys.executable)] + self.command) - self.output_updated.emit(f"Executing command: {display_command}\n") - - self.process = QProcess() - # Merge stdout and stderr so we get all output in one channel - # 合并 stdout 和 stderr,以便在一个通道中获取所有输出 - self.process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels) - if self.working_dir: - self.process.setWorkingDirectory(self.working_dir) - - # Connect signals from the process to our handlers - # 将进程的信号连接到我们的处理器 - self.process.readyReadStandardOutput.connect(self._handle_output) - self.process.finished.connect(self.process_finished.emit) - - # Start the process and wait for it to finish - # 启动进程并等待其完成 - self.process.start(sys.executable, self.command) - self.process.waitForFinished(-1) # -1 means wait indefinitely / -1表示无限期等待 - except Exception as e: - self.output_updated.emit(f"Error executing command: {e}\n") - self.process_finished.emit(1) # Emit failure signal / 发出失败信号 - - def _handle_output(self): - """ - Reads output and intelligently routes it, now parsing progress values as floats. - 读取输出并智能分发,现在将进度值解析为浮点数。 - """ - if not self.process: return - data = self.process.readAllStandardOutput().data() - try: - decoded_text = data.decode('utf-8') - except UnicodeDecodeError: - decoded_text = data.decode(self.output_encoding, errors='replace') - - for line in decoded_text.strip().split('\n'): - if not line.strip(): continue - - if line.startswith('[PROGRESS]'): - try: - payload = line[len('[PROGRESS]'):].strip() - progress_part, description = payload.split('|', 1) - current_str, max_str = progress_part.split('/', 1) - - # 将字符串解析为浮点数 - current = float(current_str.strip()) - maximum = float(max_str.strip()) - - self.progress_updated.emit(current, maximum, description.strip()) - except (ValueError, IndexError) as e: - self.output_updated.emit(f"Invalid progress format: {line}\nError: {e}\n") - else: - self.output_updated.emit(line + '\n') - - - def send_input(self, text): - """ - Sends input text to the running script (for interactive scripts). - 向正在运行的脚本发送输入文本(用于交互式脚本)。 - """ - if self.process and self.process.state() == QProcess.ProcessState.Running: - self.process.write(f"{text}\n".encode('utf-8')) - - def terminate(self): - """ - Forcefully stops the running process. - 强制停止正在运行的进程。 - """ - if self.process and self.process.state() == QProcess.ProcessState.Running: - self.process.terminate() - # If terminate() fails, kill() it after a 3-second timeout - # 如果 terminate() 失败,在3秒超时后执行 kill() - if not self.process.waitForFinished(3000): - self.process.kill() - -class EnhancedTerminalWidget(QWidget): - """ - A custom widget that emulates a basic terminal for output and input. - 一个自定义小部件,模拟用于输出和输入的基本终端。 - """ - # Signal emitted when the user presses Enter in the input line / 用户在输入行中按Enter键时发出的信号 - command_entered = pyqtSignal(str) - def apply_theme(self): - """Applies theme-aware stylesheets to terminal widgets.""" - is_dark = self.palette().color(QPalette.ColorRole.Window).lightness() < 128 - if is_dark: - self.output_display.setStyleSheet("background-color: #282c34; color: #abb2bf; font-family: Consolas, 'Courier New', monospace; font-size: 11pt;") - self.prompt_label.setStyleSheet("color: #61afef; font-weight: bold; font-size: 12pt;") - self.command_input.setStyleSheet("background-color: #282c34; color: #e06c75; font-family: Consolas, 'Courier New', monospace; font-size: 11pt; border: none;") - else: - base_color = self.palette().color(QPalette.ColorRole.Base).name() - text_color = self.palette().color(QPalette.ColorRole.Text).name() - highlight_color = self.palette().color(QPalette.ColorRole.Highlight).name() - self.output_display.setStyleSheet(f"background-color: {base_color}; color: {text_color}; font-family: Consolas, 'Courier New', monospace; font-size: 11pt;") - self.prompt_label.setStyleSheet(f"color: {highlight_color}; font-weight: bold; font-size: 12pt;") - self.command_input.setStyleSheet(f"background-color: {base_color}; color: #d12f2f; font-family: Consolas, 'Courier New', monospace; font-size: 11pt; border: none;") - - def __init__(self, parent=None): - super().__init__(parent) - self.history, self.history_index, self.current_lang = [], 0, 'zh' - self._init_ui() - - def _init_ui(self): - """ - Sets up the UI components of the terminal widget. - 设置终端小部件的UI组件。 - """ - layout = QVBoxLayout(self); layout.setContentsMargins(0, 0, 0, 0) - self.output_display = QTextEdit(); self.output_display.setReadOnly(True) - - input_layout = QHBoxLayout() - self.prompt_label = QLabel("$") - self.prompt_label.setStyleSheet("color: #61afef; font-weight: bold; font-size: 12pt;") - self.command_input = QLineEdit() - self.command_input.returnPressed.connect(self._on_command_entered) - - input_layout.addWidget(self.prompt_label) - input_layout.addWidget(self.command_input) - layout.addWidget(self.output_display) - layout.addLayout(input_layout) - - def set_language(self, lang): - """ - Sets the language for the terminal's welcome message. - 设置终端欢迎消息的语言。 - """ - self.current_lang = lang - self.output_display.clear() - self.append_output(UI_TEXTS[self.current_lang]['terminal_welcome']) - self._update_prompt() - - def _update_prompt(self): - """ - Updates the command prompt to show the current working directory. - 更新命令提示符以显示当前工作目录。 - """ - self.prompt_label.setText(f"[{os.path.basename(os.getcwd())}]$") - - def append_output(self, text): - """ - Appends text to the display, intelligently handling carriage returns ('\r') - for progress bars without overwriting normal log lines. - 将文本追加到显示区,智能地处理用于进度条的回车符('\r'),而不会覆盖正常的日志行。 - """ - cursor = self.output_display.textCursor() - cursor.movePosition(QTextCursor.MoveOperation.End) - self.output_display.setTextCursor(cursor) - - lines = text.replace('\r\n', '\n').split('\n') - - for line in lines: - if not line: - continue - - if line.endswith('\r'): - cursor.movePosition(QTextCursor.MoveOperation.StartOfLine) - cursor.movePosition(QTextCursor.MoveOperation.EndOfLine, QTextCursor.MoveMode.KeepAnchor) - cursor.removeSelectedText() - # The correct method for a QTextCursor is insertText(), not insertPlainText(). - # QTextCursor 的正确方法是 insertText(),而不是 insertPlainText()。 - cursor.insertText(line[:-1]) - else: - # The correct method for a QTextCursor is insertText(), not insertPlainText(). - # QTextCursor 的正确方法是 insertText(),而不是 insertPlainText()。 - cursor.insertText(line + '\n') - - self.output_display.ensureCursorVisible() - - - def _on_command_entered(self): - """ - Handles the user pressing Enter. It now unconditionally processes the input, - even if it's empty, mimicking a real terminal. - 处理用户按Enter键的事件。现在它会无条件地处理输入,即使是空的, - 以模仿一个真实的终端。 - """ - command = self.command_input.text() # Get the raw text - - # Add to history only if it's a non-empty command - # 仅当命令非空时才将其添加到历史记录 - if command.strip(): - self.history.append(command.strip()) - self.history_index = len(self.history) - - # Always emit the signal with the raw command. The parent will decide what to do. - # 始终使用原始命令发出信号。父级将决定如何处理。 - self.command_entered.emit(command) - - # Clear the input for the next command - # 为下一条命令清空输入 - self.command_input.clear() - - def keyPressEvent(self, event: QKeyEvent): - """ - Implements command history navigation with up/down arrow keys. - 使用上/下箭头键实现命令历史导航。 - """ - if event.key() == Qt.Key.Key_Up and self.history and self.history_index > 0: - self.history_index -= 1 - self.command_input.setText(self.history[self.history_index]) - elif event.key() == Qt.Key.Key_Down: - if self.history and self.history_index < len(self.history) - 1: - self.history_index += 1 - self.command_input.setText(self.history[self.history_index]) - else: - self.history_index = len(self.history) - self.command_input.clear() - else: - super().keyPressEvent(event) - -class ScriptGUI(QMainWindow): - """ - The main application window. - 主应用程序窗口。 - """ - - def __init__(self, scripts_dir): - """ - Initializes the main window and its components. - 初始化主窗口及其组件。 - """ - super().__init__() - - # Configuration Setup / 配置设置 - self.config_path = resource_path('config.ini') - self.config = configparser.ConfigParser() - self._load_config() - - # Instance Variables / 实例变量 - self.scripts_dir = scripts_dir - self.current_script_path, self.current_script_docstring = None, "" - self.script_executor, self.system_process = None, None - self.current_lang = 'zh' - self.undo_stack, self.redo_stack = [[]], [] - self.dynamic_param_widgets = [] - self.progress_bar = None - self.progress_label = None - self.progress_label = None - self.stop_button = None # 新增:为停止按钮添加实例变量 - - self.original_palette = QApplication.instance().palette() - - self.light_theme_action = None - self.dark_theme_action = None - self.system_theme_action = None - - # UI Initialization / UI初始化 - self._init_ui() - self._create_actions() - self.load_scripts() - self._update_ui_language() - self._apply_config() - - def _load_config(self): - """Loads settings from config.ini, creating it if it doesn't exist.""" - self.config.read(self.config_path, encoding='utf-8') - if not self.config.has_section('Preferences'): - self.config.add_section('Preferences') - - def _save_config(self): - """Saves the current settings to config.ini.""" - with open(self.config_path, 'w', encoding='utf-8') as configfile: - self.config.write(configfile) - - def _apply_config(self): - """Applies loaded configuration to the UI.""" - theme = self.config.get('Preferences', 'theme', fallback='system') - if theme == 'light': - self.light_theme_action.setChecked(True) - elif theme == 'dark': - self.dark_theme_action.setChecked(True) - else: # 'system' - self.system_theme_action.setChecked(True) - self._set_theme(theme, initializing=True) - - def _set_theme(self, theme_name, initializing=False): - """ - Sets the application's color theme and saves the preference. - :param theme_name: 'light', 'dark', or 'system'. - :param initializing: True if called during startup to prevent double saving. - """ - app = QApplication.instance() - if theme_name == 'dark': - dark_palette = QPalette() - dark_palette.setColor(QPalette.ColorRole.Window, QColor(53, 53, 53)) - dark_palette.setColor(QPalette.ColorRole.WindowText, Qt.GlobalColor.white) - dark_palette.setColor(QPalette.ColorRole.Base, QColor(25, 25, 25)) - dark_palette.setColor(QPalette.ColorRole.AlternateBase, QColor(53, 53, 53)) - dark_palette.setColor(QPalette.ColorRole.ToolTipBase, Qt.GlobalColor.white) - dark_palette.setColor(QPalette.ColorRole.ToolTipText, Qt.GlobalColor.white) - dark_palette.setColor(QPalette.ColorRole.Text, Qt.GlobalColor.white) - dark_palette.setColor(QPalette.ColorRole.Button, QColor(53, 53, 53)) - dark_palette.setColor(QPalette.ColorRole.ButtonText, Qt.GlobalColor.white) - dark_palette.setColor(QPalette.ColorRole.BrightText, Qt.GlobalColor.red) - dark_palette.setColor(QPalette.ColorRole.Link, QColor(42, 130, 218)) - dark_palette.setColor(QPalette.ColorRole.Highlight, QColor(42, 130, 218)) - dark_palette.setColor(QPalette.ColorRole.HighlightedText, Qt.GlobalColor.black) - app.setPalette(dark_palette) - else: - app.setPalette(self.original_palette) - - if not initializing: - self.config.set('Preferences', 'theme', theme_name) - self._save_config() - - self._update_widget_styles() - - def _update_widget_styles(self): - """ - Re-applies custom stylesheets to all theme-aware widgets. - This is the single source of truth for theme-dependent styles. - 将自定义样式表重新应用于所有需要感知主题的小部件。 - 这是依赖于主题的样式的唯一事实来源。 - """ - # Define explicit colors for light and dark themes - # 为浅色和深色主题定义明确的颜色 - is_dark = self.palette().color(QPalette.ColorRole.Window).lightness() < 128 - - if is_dark: - highlight_color = self.palette().color(QPalette.ColorRole.Highlight).name() - # For dark mode, highlighted text is often black or a dark color - # 对于深色模式,高亮文本通常是黑色或深色 - highlighted_text_color = self.palette().color(QPalette.ColorRole.HighlightedText).name() - else: - # For light mode, explicitly set highlighted text to white for contrast - # 对于浅色模式,为形成对比,明确地将高亮文本设置为白色 - highlight_color = self.palette().color(QPalette.ColorRole.Highlight).name() - highlighted_text_color = "#ffffff" # Explicitly white / 明确设为白色 - - style = f""" - QListWidget {{ outline: 0; }} - QListWidget::item {{ padding: 4px; }} - QListWidget::item:selected {{ - background-color: {highlight_color}; - color: {highlighted_text_color}; - border-radius: 4px; - }} - """ - self.path_list_widget.setStyleSheet(style) - self.script_list.setStyleSheet(style) - - # Update Enhanced Terminal Style - # 更新增强型终端的样式 - self.terminal.apply_theme() - - def _init_ui(self): - """ - Initializes the overall UI layout and sub-panels. - 初始化整体UI布局和子面板。 - """ - self._create_menu_bar() - self.setGeometry(100, 100, 1200, 800) - central_widget = QWidget() - self.setCentralWidget(central_widget) - # Enable drag-and-drop on the main window - # 在主窗口上启用拖放功能 - self.setAcceptDrops(True) - - main_layout = QHBoxLayout(central_widget) - main_splitter = QSplitter(Qt.Orientation.Horizontal) - main_layout.addWidget(main_splitter) - - left_panel, right_panel = self._create_left_panel(), self._create_right_panel() - main_splitter.addWidget(left_panel) - main_splitter.addWidget(right_panel) - main_splitter.setSizes([400, 800]) # Initial size ratio / 初始尺寸比例 - - self.statusBar() - self.setStyleSheet("QPushButton { min-height: 30px; padding: 5px; } QLineEdit, QComboBox { min-height: 28px; }") - self._update_widget_styles() - - def _create_menu_bar(self): - """ - Creates the main menu bar and connects theme switching actions. - 为应用程序创建主菜单栏,并连接主题切换动作。 - """ - self.menu_bar = self.menuBar() - lang = UI_TEXTS[self.current_lang] - - # Preferences Menu / 偏好设置菜单 - self.prefs_menu = self.menu_bar.addMenu(lang['prefs_menu']) - - # Theme Sub-Menu / 主题子菜单 - self.theme_menu = self.prefs_menu.addMenu(lang['theme_menu']) - - theme_group = QActionGroup(self) - theme_group.setExclusive(True) - - # Create actions and connect them to the theme-setting method - # 创建动作并将其连接到主题设置方法 - self.light_theme_action = QAction(lang['theme_light'], self, checkable=True) - self.light_theme_action.triggered.connect(lambda: self._set_theme('light')) - - self.dark_theme_action = QAction(lang['theme_dark'], self, checkable=True) - self.dark_theme_action.triggered.connect(lambda: self._set_theme('dark')) - - self.system_theme_action = QAction(lang['theme_system'], self, checkable=True) - self.system_theme_action.triggered.connect(lambda: self._set_theme('system')) - self.system_theme_action.setChecked(True) # Default option / 默认选项 - - theme_group.addAction(self.light_theme_action) - theme_group.addAction(self.dark_theme_action) - theme_group.addAction(self.system_theme_action) - - self.theme_menu.addAction(self.light_theme_action) - self.theme_menu.addAction(self.dark_theme_action) - self.theme_menu.addAction(self.system_theme_action) - - - - def _create_left_panel(self): - """ - Creates the left panel containing the script list and info display. - 创建包含脚本列表和信息显示的左侧面板。 - """ - panel = QWidget() - layout = QVBoxLayout(panel) - - header_layout = QHBoxLayout() - self.script_list_label = QLabel() - header_layout.addWidget(self.script_list_label) - header_layout.addStretch() - self.refresh_button = QPushButton("🔄") - self.refresh_button.clicked.connect(self._refresh_scripts) - self.refresh_button.setFixedSize(30, 30) - header_layout.addWidget(self.refresh_button) - layout.addLayout(header_layout) - - self.script_list = QListWidget() - self.script_list.setAlternatingRowColors(True) - self.script_list.itemClicked.connect(self._on_script_selected) - # 1. 激活自定义上下文菜单策略 - self.script_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) - # 2. 连接信号到我们即将创建的处理器 - self.script_list.customContextMenuRequested.connect(self._show_script_context_menu) - # 3. 正确处理高亮样式 - self.script_info_label = QLabel() - self.script_info = QTextEdit() - self.script_info.setReadOnly(True) - - self.switch_lang_button = QPushButton() - self.switch_lang_button.clicked.connect(self._toggle_language) - - layout.addWidget(self.script_list) - layout.addWidget(self.script_info_label) - layout.addWidget(self.script_info) - layout.addWidget(self.switch_lang_button) - - return panel - - def _create_right_panel(self): - """ - Creates the right panel containing the file list, parameters, and output tabs. - 创建包含文件列表、参数和输出选项卡的右侧面板。 - """ - panel = QWidget() - layout = QVBoxLayout(panel) - - # File list section / 文件列表部分 - self.path_list_label = QLabel() - self.path_list_widget = QListWidget() - self.path_list_widget.setSelectionMode(QListWidget.SelectionMode.ExtendedSelection) - self.path_list_widget.itemChanged.connect(self._on_path_item_changed) - self.path_list_widget.itemDoubleClicked.connect(self.path_list_widget.editItem) - layout.addWidget(self.path_list_label) - layout.addWidget(self.path_list_widget) - - # File management buttons / 文件管理按钮部分 - button_layout = QHBoxLayout() - self.browse_files_button = QPushButton() - self.browse_files_button.clicked.connect(self._browse_files) - self.browse_dir_button = QPushButton() - self.browse_dir_button.clicked.connect(self._browse_directories) - # Create the new "Select/Deselect All" button - # 创建新的“勾选/取消全选”按钮 - self.select_all_button = QPushButton() - self.select_all_button.clicked.connect(self._on_select_all_button_clicked) - self.remove_button = QPushButton() - self.remove_button.clicked.connect(self._on_remove_button_clicked) - button_layout.addWidget(self.browse_files_button) - button_layout.addWidget(self.browse_dir_button) - button_layout.addStretch() - # Add the new button to the layout, to the left of the remove button - # 将新按钮添加到布局中,位于移除按钮的左侧 - button_layout.addWidget(self.select_all_button) - button_layout.addWidget(self.remove_button) - layout.addLayout(button_layout) - - # Dynamic parameter section / 动态参数部分 - self.dynamic_params_group = QWidget() - self.dynamic_params_layout = QGridLayout(self.dynamic_params_group) - self.dynamic_params_layout.setContentsMargins(0, 10, 0, 10) - self.dynamic_params_layout.setSpacing(10) - layout.addWidget(self.dynamic_params_group) - - # Separator line / 分隔线 - line = QFrame() - line.setFrameShape(QFrame.Shape.HLine) - line.setFrameShadow(QFrame.Shadow.Sunken) - layout.addWidget(line) - - # Manual parameter section / 手动参数部分 - params_layout = QHBoxLayout() - self.params_label = QLabel() - self.script_params = QLineEdit() - params_layout.addWidget(self.params_label) - params_layout.addWidget(self.script_params) - layout.addLayout(params_layout) - - # Run and Stop buttons / 执行与停止按钮 - run_stop_layout = QHBoxLayout() - run_stop_layout.setSpacing(10) # 设置两个按钮之间的空隙为10像素 - - # 执行按钮 - self.run_button = QPushButton() - self.run_button.clicked.connect(self._run_script) - self.run_button.setEnabled(False) - self.run_button.setStyleSheet("QPushButton { font-weight: bold; font-size: 14pt; padding: 8px; min-height: 40px; }") - - # 新增:停止按钮 - self.stop_button = QPushButton() - self.stop_button.clicked.connect(self._stop_script) - self.stop_button.setEnabled(False) # 初始时禁用 - self.stop_button.setStyleSheet("QPushButton { font-weight: bold; font-size: 14pt; padding: 8px; min-height: 40px; color: #D32F2F; }") # 使用红色以示区别 - - run_stop_layout.addWidget(self.run_button) - run_stop_layout.addWidget(self.stop_button) - layout.addLayout(run_stop_layout) # 将布局添加到主布局中 # Progress Bar Section / 进度条部分 - self.progress_bar = QProgressBar() - self.progress_bar.setTextVisible(True) - self.progress_bar.setRange(0, 100) - self.progress_bar.setValue(0) - self.progress_bar.hide() - - self.progress_label = QLabel("...") - self.progress_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.progress_label.hide() - - layout.addWidget(self.progress_bar) - layout.addWidget(self.progress_label) - - # Output tabs / 输出选项卡 - self.output_label = QLabel() - self.tabs = QTabWidget() - self.console = QTextEdit() # The "Standard Output" tab / “标准输出”选项卡 - self.console.setReadOnly(True) - self.console.setStyleSheet("background-color: #282c34; color: #abb2bf; font-family: Consolas, 'Courier New', monospace;") - self.terminal = EnhancedTerminalWidget() # The "Enhanced Terminal" tab / “增强型终端”选项卡 - self.terminal.command_entered.connect(self._handle_terminal_input) - self.tabs.addTab(self.console, "") - self.tabs.addTab(self.terminal, "") - layout.addWidget(self.output_label) - layout.addWidget(self.tabs) - - return panel - - def _dynamic_params_resize_event(self, event): - # 获取参数区当前宽度 - total_width = self.dynamic_params_group.width() - # 设1/3为单控件最大宽度,设置一个最小宽度防止太窄 - max_param_width = max(int(total_width / 3), 180) - # 遍历所有动态参数控件,设置最大宽度 - for widget in self.dynamic_param_widgets: - widget.setMaximumWidth(max_param_width) - # 保持原有resizeEvent行为 - QWidget.resizeEvent(self.dynamic_params_group, event) - - # 绑定新的resizeEvent - self.dynamic_params_group.resizeEvent = self._dynamic_params_resize_event.__get__(self.dynamic_params_group) - - - def _create_actions(self): - """ - Creates global actions and shortcuts (e.g., Undo, Redo). - 创建全局操作和快捷键(例如,撤销、重做)。 - """ - self.undo_action = QAction("Undo", self) - self.undo_action.setShortcut(QKeySequence("Ctrl+Z")) - self.undo_action.triggered.connect(self.undo) - self.addAction(self.undo_action) - - self.redo_action = QAction("Redo", self) - # Set platform-specific shortcut for Redo / 为重做设置特定于平台的快捷键 - if platform.system() == "Darwin": # macOS - self.redo_action.setShortcut(QKeySequence("Ctrl+Shift+Z")) - else: # Windows, Linux - self.redo_action.setShortcut(QKeySequence("Ctrl+Y")) - self.redo_action.triggered.connect(self.redo) - self.addAction(self.redo_action) - - def _update_ui_language(self): - """ - Updates all text labels in the UI to the currently selected language. - 将UI中的所有文本标签更新为当前选择的语言。 - """ - lang = UI_TEXTS[self.current_lang] - self.setWindowTitle(lang['window_title']) - self.script_list_label.setText(lang['available_scripts']) - self.script_info_label.setText(lang['script_info']) - self.path_list_label.setText(lang['path_list_label']) - self.run_button.setText(lang['run_button']) - self.run_button.setText(lang['run_button']) - self.stop_button.setText(lang.get('stop_button', 'Stop Script')) # 新增:设置停止按钮的文本 - self.browse_files_button.setText(lang['browse_files_button']) - self.browse_dir_button.setText(lang['browse_dir_button']) - self.params_label.setText(lang['params_label']) - self.script_params.setPlaceholderText(lang['params_placeholder']) - self.output_label.setText(lang['output_label']) - self.tabs.setTabText(0, lang['stdout_tab']) - self.tabs.setTabText(1, lang['terminal_tab']) - self.statusBar().showMessage(lang['status_ready']) - self.switch_lang_button.setText(lang['switch_lang_button']) - self.refresh_button.setToolTip(lang['refresh_button_tooltip']) - self.terminal.set_language(self.current_lang) - self._update_script_list_display() - self._update_remove_button_state() - self._update_script_info_display() - self._update_select_all_button_state() - # Update menu text if menus have been created - # 如果菜单已创建,则更新菜单文本 - if hasattr(self, 'prefs_menu'): - self.prefs_menu.setTitle(lang['prefs_menu']) - self.theme_menu.setTitle(lang['theme_menu']) - self.light_theme_action.setText(lang['theme_light']) - self.dark_theme_action.setText(lang['theme_dark']) - self.system_theme_action.setText(lang['theme_system']) - - - def _toggle_language(self): - """ - Switches the UI language between Chinese and English. - 在中英文之间切换UI语言。 - """ - self.current_lang = 'en' if self.current_lang == 'zh' else 'zh' - self._update_ui_language() - - def _refresh_scripts(self): - """ - Reloads the list of available scripts from the scripts directory. - 从脚本目录重新加载可用脚本列表。 - """ - self.script_list.clear() - self.script_info.clear() - self.current_script_path = None - self.run_button.setEnabled(False) - self.stop_button.setEnabled(True) # 新增:脚本运行时启用停止按钮 self._clear_dynamic_params_ui() - self.load_scripts() - self.statusBar().showMessage(UI_TEXTS[self.current_lang]['status_ready'], 2000) - - def load_scripts(self): - """ - Scans the scripts directory, extracts display names, and populates the script list. - 扫描脚本目录,提取显示名称,并填充脚本列表。 - """ - if not os.path.exists(self.scripts_dir): - os.makedirs(self.scripts_dir) - return - - for script_path in sorted(glob.glob(os.path.join(self.scripts_dir, "*.py"))): - try: - with open(script_path, 'r', encoding='utf-8') as f: - content = f.read() - docstring = self._extract_docstring(content) - - # Extract display names for both languages from the docstring. - # 从文档字符串中为两种语言提取显示名称。 - zh_name_match = re.search(r'\[display-name-zh\](.*?)\n', docstring) - en_name_match = re.search(r'\[display-name-en\](.*?)\n', docstring) - - # Use extracted names if found, otherwise fall back to the filename. - # 如果找到则使用提取的名称,否则回退到使用文件名。 - zh_name = zh_name_match.group(1).strip() if zh_name_match else os.path.basename(script_path) - en_name = en_name_match.group(1).strip() if en_name_match else os.path.basename(script_path) - - item = QListWidgetItem() # Create an empty item first / 首先创建一个空项目 - - # Store all necessary data in the item. The text will be set by _update_ui_language. - # 将所有必要的数据存储在项目中。其显示的文本将由 _update_ui_language 设置。 - item.setData(Qt.ItemDataRole.UserRole, { - 'path': script_path, - 'name_zh': zh_name, - 'name_en': en_name - }) - - self.script_list.addItem(item) - except Exception as e: - print(f"Error loading script {script_path}: {e}") - - # After loading, update the display text for all items based on the current language. - # 加载后,根据当前语言更新所有项目的显示文本。 - self._update_script_list_display() - def _update_script_list_display(self): - """ - Updates the display text of all items in the script list based on the current UI language. - 根据当前UI语言,更新脚本列表中所有项目的显示文本。 - """ - for i in range(self.script_list.count()): - item = self.script_list.item(i) - data = item.data(Qt.ItemDataRole.UserRole) - # Ensure data exists before trying to access it - # 确保在访问数据前其存在 - if data: - display_name = data['name_zh'] if self.current_lang == 'zh' else data['name_en'] - item.setText(display_name) - - def _show_script_context_menu(self, position): - """ - Creates and displays a context menu when a script item is right-clicked. - 当脚本项被右键点击时,创建并显示上下文菜单。 - """ - # Get the item under the cursor - # 获取光标下的项目 - item = self.script_list.itemAt(position) - if not item: - return - - # Retrieve the script's data - # 检索脚本的数据 - script_data = item.data(Qt.ItemDataRole.UserRole) - script_path = script_data.get('path') - if not script_path: - return - - script_filename = os.path.basename(script_path) - lang = UI_TEXTS[self.current_lang] - - # Create the context menu - # 创建上下文菜单 - context_menu = QMenu(self) - - # Add the filename as a disabled title item - # 将文件名作为禁用的标题项添加 - filename_action = QAction(script_filename, self) - filename_action.setEnabled(False) - context_menu.addAction(filename_action) - - context_menu.addSeparator() - - # Add the "Show in Folder" action - # 添加“在文件夹中显示”动作 - show_action = QAction("在文件夹中显示", self) - show_action.triggered.connect(lambda: self._open_in_file_explorer(script_path)) - context_menu.addAction(show_action) - - # Show the menu at the cursor's global position - # 在光标的全局位置显示菜单 - context_menu.exec(self.script_list.mapToGlobal(position)) - def _open_in_file_explorer(self, path): - """ - Opens the system's file explorer and highlights the given file. - This function is platform-aware. - 打开系统的文件资源管理器并高亮给定的文件。 - 此函数能感知平台差异。 - """ - if not os.path.exists(path): - return - - system = platform.system() - try: - if system == "Windows": - # The /select switch selects and highlights the item. - # /select 开关会选中并高亮该项。 - subprocess.run(['explorer', '/select,', os.path.normpath(path)]) - elif system == "Darwin": # macOS - # The -R switch reveals the file in Finder. - # -R 开关会在Finder中显示该文件。 - subprocess.run(['open', '-R', path]) - else: # Linux and other Unix-like systems - # Highlighting is not universally supported, so we just open the directory. - # 高亮功能并非被普遍支持,因此我们只打开其所在的目录。 - dir_path = os.path.dirname(path) - subprocess.run(['xdg-open', dir_path]) - except Exception as e: - print(f"Error opening file explorer: {e}") - # Fallback for safety: just print the path - # 安全回退:仅打印路径 - QMessageBox.information(self, "File Path", f"Could not open explorer.\nThe file is located at:\n{path}") - - def _on_script_selected(self, item): - """ - Handles the event when a script is selected from the list. - 处理从列表中选择脚本的事件。 - """ - script_data = item.data(Qt.ItemDataRole.UserRole) - self.current_script_path = script_data['path'] - try: - with open(self.current_script_path, 'r', encoding='utf-8') as f: - content = f.read() - self.current_script_docstring = self._extract_docstring(content) - self.run_button.setEnabled(True) - # Parse the script for argparse parameters to build the dynamic UI - # 解析脚本的argparse参数以构建动态UI - params = self._parse_script_for_params(self.current_script_path) - self._update_dynamic_params_ui(params) - except Exception as e: - self.current_script_docstring = f"{UI_TEXTS[self.current_lang]['info_read_error']}{e}" - self.run_button.setEnabled(False) - self._clear_dynamic_params_ui() - self._update_script_info_display() - - def _parse_script_for_params(self, script_path): - """ - Runs the script with '--help' to capture and parse its argparse parameters. - This version uses a robust regex to differentiate between flags, value inputs, and choices - based on the presence of a metavar or a choice list. - 使用'--help'运行脚本以捕获并解析其argparse参数。 - 此版本使用健壮的正则表达式,根据元变量或选项列表的存在来区分标志、值输入和选项。 - """ - params = [] - try: - result = subprocess.run( - [sys.executable, script_path, '--help'], - capture_output=True, text=True, encoding='utf-8', errors='replace' - ) - help_text = result.stdout - - # This new regex is designed to capture the key parts of an argparse argument definition. - # 这个新正则表达式旨在捕获argparse参数定义的关键部分。 - # Group 1: Optional short flag (e.g., '-v, ') - # Group 2: The long flag name (e.g., 'verbose') - # Group 3: Optional metavar, indicating a value is expected (e.g., 'GROUP_SIZE') - # Group 4: Optional choices list (e.g., 'name,date') - # Group 5: Optional default value - pattern = re.compile( - r"^\s+(-[a-zA-Z],\s+)?--([a-zA-Z0-9_-]+)\s*([A-Z_]+)?.*?(?:\{([^}]+)\})?.*?(?:\(default:\s*([^)]+)\))?", - re.MULTILINE | re.IGNORECASE - ) - - for match in pattern.finditer(help_text): - name = f"--{match.group(2)}" - # Ignore internal flags - # 忽略内部标志 - if name in ['--gui-mode', '--lang']: continue - - metavar = match.group(3) - choices = match.group(4) - default_val = match.group(5) - - param_info = {'name': name} - - if choices: - # If it has choices, it's a ComboBox. - # 如果有选项,它就是下拉框。 - param_info['type'] = 'choice' - param_info['choices'] = [c.strip() for c in choices.split(',')] - elif metavar: - # If it has a metavar but no choices, it's a LineEdit (value input). - # 如果有元变量但没有选项,它就是输入框(值输入)。 - param_info['type'] = 'value' - else: - # Otherwise, it's a CheckBox (flag). - # 否则,它就是复选框(标志)。 - param_info['type'] = 'flag' - - if default_val: - param_info['default'] = default_val.strip() - - params.append(param_info) - - except Exception as e: - print(f"Could not parse parameters for {os.path.basename(script_path)}: {e}") - return params - - def _clear_dynamic_params_ui(self): - """ - Removes all dynamically generated parameter widgets from the layout. - 从布局中移除所有动态生成的参数小部件。 - """ - while self.dynamic_params_layout.count(): - item = self.dynamic_params_layout.takeAt(0) - if item.widget(): - item.widget().deleteLater() - self.dynamic_param_widgets = [] - self.dynamic_params_group.setVisible(False) - - def _update_dynamic_params_ui(self, params): - """ - Creates and displays UI widgets based on parsed parameters. - This version now supports internationalized display names for choices - by parsing a special format in the help string: [display: value=zh_name,en_name]. - 根据解析出的参数创建并显示UI小部件。 - 此版本现在通过解析帮助字符串中的特殊格式 [display: value=zh_name,en_name] 来支持选项的国际化显示名称。 - """ - self._clear_dynamic_params_ui() - if not params: return - - params.sort(key=lambda x: x['name']) - - row, col, max_rows = 0, 0, 3 - for param in params: - name = param['name'] - p_type = param.get('type', 'flag') - p_default = param.get('default') - # (NEW) Get the full help text for parsing display names. - # (新) 获取完整的帮助文本以解析显示名称。 - p_help = param.get('help', '') - - widget = None - if p_type == 'choice': - label = QLabel(f"{name}:") - combo = QComboBox() - - # (NEW) Logic to parse display names from help text. - # (新) 从帮助文本中解析显示名称的逻辑。 - display_map = {} - display_match = re.search(r'\[display:\s*(.*?)\]', p_help) - if display_match: - # e.g., "asc=升序,Ascending | desc=降序,Descending" - entries = display_match.group(1).split('|') - for entry in entries: - try: - value, names = entry.split('=', 1) - zh_name, en_name = names.split(',', 1) - display_map[value.strip()] = {'zh': zh_name.strip(), 'en': en_name.strip()} - except ValueError: - continue # Ignore malformed entries - - # Populate the ComboBox - for choice_value in param['choices']: - display_name = choice_value # Default to the internal value - if choice_value in display_map: - display_name = display_map[choice_value].get(self.current_lang, choice_value) - - # Add the display name to the UI, but store the internal value in UserRole. - # 将显示名称添加到UI,但将内部值存储在UserRole中。 - combo.addItem(display_name, userData=choice_value) - - # Set the default value based on the internal value, not the display name. - # 根据内部值而不是显示名称来设置默认值。 - if p_default: - index = combo.findData(p_default) - if index != -1: - combo.setCurrentIndex(index) - - widget = combo - self.dynamic_params_layout.addWidget(label, row, col * 2) - self.dynamic_params_layout.addWidget(widget, row, col * 2 + 1) - - elif p_type == 'value': - label = QLabel(f"{name}:") - line_edit = QLineEdit() - if p_default: line_edit.setText(p_default) - widget = line_edit - self.dynamic_params_layout.addWidget(label, row, col * 2) - self.dynamic_params_layout.addWidget(widget, row, col * 2 + 1) - - else: # 'flag' type - checkbox = QCheckBox(name) - if p_default: checkbox.setChecked(True) - widget = checkbox - self.dynamic_params_layout.addWidget(widget, row, col * 2, 1, 2) - - if widget: - widget.setToolTip(p_help.split('[display:')[0].strip()) # Use help text before our tag as tooltip - widget.setProperty('param_name', name) - widget.setProperty('param_type', p_type) - self.dynamic_param_widgets.append(widget) - - row += 1 - if row >= max_rows: - row = 0 - col += 1 - - self.dynamic_params_group.setVisible(True) - if self.dynamic_param_widgets: - total_width = self.dynamic_params_group.width() - max_param_width = max(int(total_width / 3), 180) - for widget in self.dynamic_param_widgets: - widget.setMaximumWidth(max_param_width) - - def _update_script_info_display(self): - """ - Extracts and displays the relevant part of the script's docstring based on the current UI language, - using '~~~' as the primary language separator. - 根据当前的UI语言,提取并显示脚本文档字符串的相关部分。 - 使用 '~~~' 作为主要的语言分隔符。 - """ - doc = self.current_script_docstring - display_text = doc if doc else UI_TEXTS[self.current_lang]['info_no_docstring'] - - # New logic: Use '~~~' to split the docstring into language blocks. - # 新逻辑: 使用 '~~~' 将文档字符串分割为语言块。 - if '~~~' in doc: - try: - # Assume the first part is Chinese, the second is English. - # 假设第一部分是中文,第二部分是英文。 - parts = doc.split('~~~', 1) - chinese_doc = parts[0] - english_doc = parts[1] if len(parts) > 1 else '' - - # Select the correct block based on the current language. - # 根据当前语言选择正确的块。 - selected_doc = chinese_doc if self.current_lang == 'zh' else english_doc - - # Remove the initial display name tags from the selected block for clean display. - # 从选定的块中移除开头的显示名称标签,以实现干净的显示。 - display_text = re.sub(r'\[display-name-..\](.*?)\n', '', selected_doc, count=2).strip() - - except Exception as e: - # If parsing fails, fall back to showing the whole docstring. - # 如果解析失败,则回退到显示整个文档字符串。 - print(f"Error parsing docstring with '~~~': {e}") - display_text = doc - - self.script_info.setText(display_text) - def _extract_docstring(self, content): - """ - Extracts the module-level docstring from a script's content using regex. - 使用正则表达式从脚本内容中提取模块级别的文档字符串。 - """ - match = re.search(r'^\s*("""(.*?)"""|\'\'\'(.*?)\'\'\')', content, re.DOTALL | re.MULTILINE) - return (match.group(2) or match.group(3)).strip() if match else "" - - def dragEnterEvent(self, event): - """ - Accepts drag-and-drop events if they contain file URLs. - 如果拖放事件包含文件URL,则接受该事件。 - """ - if event.mimeData().hasUrls(): - event.acceptProposedAction() - - def dropEvent(self, event): - """ - Handles dropped files by adding them to the path list. - 通过将文件添加到路径列表来处理拖放的文件。 - """ - self._save_state_for_undo() - for url in event.mimeData().urls(): - self._add_path_to_list(url.toLocalFile()) - self._update_remove_button_state() - - def _add_path_to_list(self, path): - """ - Adds a single path to the QListWidget with checkbox and editable flags. - 将单个路径添加到QListWidget,并带有复选框和可编辑标志。 - """ - if os.path.exists(path): - item = QListWidgetItem(path) - item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEditable) - item.setCheckState(Qt.CheckState.Unchecked) - self.path_list_widget.addItem(item) - - def _browse_files(self): - """ - Opens a file dialog to add multiple files. - 打开文件对话框以添加多个文件。 - """ - self._save_state_for_undo() - files, _ = QFileDialog.getOpenFileNames(self, UI_TEXTS[self.current_lang]['browse_files_button']) - if files: - for file in files: - self._add_path_to_list(file) - self._update_remove_button_state() - - def _browse_directories(self): - """ - Opens a directory dialog to add multiple directories. - 打开目录对话框以添加多个目录。 - """ - self._save_state_for_undo() - dialog = QFileDialog(self, UI_TEXTS[self.current_lang]['browse_dir_button']) - dialog.setFileMode(QFileDialog.FileMode.Directory) - dialog.setOption(QFileDialog.Option.ShowDirsOnly, True) - if dialog.exec(): - directories = dialog.selectedFiles() - if directories: - for directory in directories: - self._add_path_to_list(directory) - self._update_remove_button_state() - - def _on_path_item_changed(self, item): - """ - Saves state for undo when a path item's text or check state changes. - 当路径项的文本或勾选状态更改时,保存状态以供撤销。 - """ - self._save_state_for_undo() - self._update_remove_button_state() - self._update_select_all_button_state() - - - - def _on_remove_button_clicked(self): - """ - Removes the union of all selected and checked items. If none are marked, clears the entire list. - 移除所有被选中和被勾选项目的并集。如果没有任何项目被标记,则清空整个列表。 - """ - self._save_state_for_undo() - - # QListWidgetItem is not hashable, so we cannot use a set directly. - # We must manually create a list of unique items. - # QListWidgetItem 不可哈希,所以我们不能直接使用set。 - # 我们必须手动创建一个包含唯一项的列表。 - selected_items = self.path_list_widget.selectedItems() - checked_items = self._get_checked_items() - - combined_list = selected_items + checked_items - items_to_delete = [] - for item in combined_list: - if item not in items_to_delete: - items_to_delete.append(item) - - if items_to_delete: - # If any items are marked, remove them. - # 如果有任何项目被标记,则移除它们。 - for item in items_to_delete: # Iterating over the new unique list - self.path_list_widget.takeItem(self.path_list_widget.row(item)) - else: - # If no items are marked, clear the entire list. - # 如果没有任何项目被标记,则清空整个列表。 - self.path_list_widget.clear() - - # Update the state of related buttons. - # 更新相关按钮的状态。 - self._update_remove_button_state() - self._update_select_all_button_state() - - def _get_checked_items(self): - """ - Returns a list of all checked items in the path list. - 返回路径列表中所有被勾选的项目。 - """ - return [self.path_list_widget.item(i) for i in range(self.path_list_widget.count()) if self.path_list_widget.item(i).checkState() == Qt.CheckState.Checked] - - def _update_remove_button_state(self): - """ - Updates the remove button text. It shows "Remove Selected" if any item is selected OR checked. - 更新移除按钮的文本。如果任何项目被选中或被勾选,按钮将显示“移除选中项”。 - """ - lang = UI_TEXTS[self.current_lang] - - # Check if any item is either selected (highlighted) or checked. - # 检查是否有任何项目被选中(高亮)或被勾选。 - is_any_item_marked = bool(self.path_list_widget.selectedItems() or self._get_checked_items()) - - if is_any_item_marked: - # The user has marked items for action. - # 用户已经标记了要操作的项目。 - self.remove_button.setText(lang['remove_selected_button']) - else: - # No items are marked, the button's action will be to clear all. - # 没有任何项目被标记,按钮的操作将是清空所有。 - self.remove_button.setText(lang['remove_all_button']) - - def _update_select_all_button_state(self): - """ - Updates the text of the "Select/Deselect All" button based on the current check state. - 根据当前的勾选状态,更新“勾选/取消全选”按钮的文本。 - """ - lang = UI_TEXTS[self.current_lang] - # If any item is checked, the button should offer to "Deselect All". - # 如果有任何一项被勾选,按钮应提供“取消全选”功能。 - if any(self._get_checked_items()): - self.select_all_button.setText(lang['deselect_all']) - # Otherwise, it should offer to "Select All". - # 否则,它应提供“勾选全部”功能。 - else: - self.select_all_button.setText(lang['select_all']) - - def _on_select_all_button_clicked(self): - """ - Handles the click event for the "Select/Deselect All" button. - 处理“勾选/取消全选”按钮的点击事件。 - """ - self._save_state_for_undo() - - # Determine the action based on whether any items are currently checked. - # 根据当前是否有项目被勾选来决定执行何种操作。 - is_anything_checked = any(self._get_checked_items()) - - # The new state to be applied to all items. - # 将要应用到所有项目的新状态。 - new_state = Qt.CheckState.Unchecked if is_anything_checked else Qt.CheckState.Checked - - for i in range(self.path_list_widget.count()): - self.path_list_widget.item(i).setCheckState(new_state) - - # After the action, update the button's text for the next click. - # 操作完成后,更新按钮的文本以备下次点击。 - self._update_select_all_button_state() - - def _save_state_for_undo(self): - """ - Saves the current state of the path list to the undo stack. - 将路径列表的当前状态保存到撤销堆栈。 - """ - state = [{'text': self.path_list_widget.item(i).text(), 'checked': self.path_list_widget.item(i).checkState() == Qt.CheckState.Checked} for i in range(self.path_list_widget.count())] - if not self.undo_stack or state != self.undo_stack[-1]: - self.undo_stack.append(state) - self.redo_stack.clear() - - def _restore_state(self, state): - """ - Restores the path list to a previous state from the undo/redo stack. - 从撤销/重做堆栈中将路径列表恢复到先前的状态。 - """ - self.path_list_widget.clear() - for item_data in state: - item = QListWidgetItem(item_data['text']) - item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable | Qt.ItemFlag.ItemIsEditable) - item.setCheckState(Qt.CheckState.Checked if item_data['checked'] else Qt.CheckState.Unchecked) - self.path_list_widget.addItem(item) - self._update_remove_button_state() - self._update_select_all_button_state() - - def undo(self): - """ - Performs the undo action. - 执行撤销操作。 - """ - if len(self.undo_stack) > 1: - self.redo_stack.append(self.undo_stack.pop()) - self._restore_state(self.undo_stack[-1]) - else: - self.statusBar().showMessage(UI_TEXTS[self.current_lang]['undo_stack_empty'], 2000) - - def redo(self): - """ - Performs the redo action. - 执行重做操作。 - """ - if self.redo_stack: - state_to_restore = self.redo_stack.pop() - self.undo_stack.append(state_to_restore) - self._restore_state(state_to_restore) - else: - self.statusBar().showMessage(UI_TEXTS[self.current_lang]['redo_stack_empty'], 2000) - - - def keyPressEvent(self, event: QKeyEvent): - """ - Handles global key presses for shortcuts. - 处理快捷键的全局按键事件。 - """ - # Esc to uncheck all items / Esc键取消所有勾选 - if event.key() == Qt.Key.Key_Escape: - changed = False - for item in self._get_checked_items(): - item.setCheckState(Qt.CheckState.Unchecked) - changed = True - if changed: - self._save_state_for_undo() - self._update_select_all_button_state() - return - - # Delete/Backspace to remove selected and/or checked items - # Delete/Backspace键移除被选中和/或被勾选的项目 - if self.path_list_widget.hasFocus() and (event.key() == Qt.Key.Key_Delete or event.key() == Qt.Key.Key_Backspace): - # Get the union of selected and checked items manually, because QListWidgetItem is not hashable. - # 手动获取选中和勾选项目的并集,因为QListWidgetItem不可哈希。 - selected_items = self.path_list_widget.selectedItems() - checked_items = self._get_checked_items() - - combined_list = selected_items + checked_items - items_to_delete = [] - for item in combined_list: - if item not in items_to_delete: - items_to_delete.append(item) - - if items_to_delete: - self._save_state_for_undo() - for item in items_to_delete: - self.path_list_widget.takeItem(self.path_list_widget.row(item)) - - # Update button states after deletion. - # 删除后更新按钮状态。 - self._update_remove_button_state() - self._update_select_all_button_state() - return - - super().keyPressEvent(event) - def _append_to_console(self, text): - """ - Appends text to the "Standard Output" tab. - 将文本追加到“标准输出”选项卡。 - """ - cursor = self.console.textCursor() - cursor.movePosition(QTextCursor.MoveOperation.End) - self.console.setTextCursor(cursor) - self.console.insertPlainText(text) - self.console.ensureCursorVisible() - - def _update_progress_display(self, current_float, max_float, description): - """ - Updates the progress bar by mapping float progress values to an integer scale. - 通过将浮点进度值映射到整数刻度来更新进度条。 - """ - if self.progress_bar.isHidden(): - self.progress_bar.show() - self.progress_label.show() - - # 为了平滑显示,我们将浮点数乘以100转换为整数 - # 例如:(2.5 / 41.58) -> (250 / 4158) - max_int = int(max_float * 100) - current_int = int(current_float * 100) - - self.progress_bar.setRange(0, max_int) - self.progress_bar.setValue(current_int) - self.progress_label.setText(description) - - def _run_script(self): - """ - Assembles the command and starts the ScriptExecutor thread. - It now intelligently selects paths based on checkbox states. - 组装命令并启动ScriptExecutor线程。 - 现在会根据复选框状态智能选择路径。 - """ - lang = UI_TEXTS[self.current_lang] - if not self.current_script_path: - QMessageBox.warning(self, lang['warn_select_script_title'], lang['warn_select_script_msg']); return - - # First, get the list of all checked items. - # 首先,获取所有被勾选项目的列表。 - checked_items = self._get_checked_items() - - paths = [] - if checked_items: - # If there are checked items, process ONLY those. - # 如果有项目被勾选,则只处理这些项目。 - paths = [item.text() for item in checked_items] - else: - # If no items are checked, process ALL items in the list. - # 如果没有项目被勾选,则处理列表中的所有项目。 - paths = [self.path_list_widget.item(i).text() for i in range(self.path_list_widget.count())] - - if not paths: - QMessageBox.warning(self, lang['warn_no_paths_title'], lang['warn_no_paths_msg']); return - - # Build the argument list, starting with the GUI flag - # 构建参数列表,以GUI标志开头 - arguments = ["--gui-mode"] - # Pass the current language to the script - # 将当前语言传递给脚本 - arguments.extend(["--lang", self.current_lang]) - # Add arguments from dynamic widgets - # 从动态小部件添加参数 - for widget in self.dynamic_param_widgets: - p_name = widget.property('param_name') - p_type = widget.property('param_type') - if p_type == 'flag' and widget.isChecked(): - arguments.append(p_name) - elif p_type == 'choice': - # (MODIFIED) Get the internal value from userData. - # (已修改) 从userData获取内部值。 - internal_value = widget.currentData() - arguments.extend([p_name, internal_value]) - elif p_type == 'value': - value = widget.text() - if value: arguments.extend([p_name, value]) - - # Add arguments from the manual input box - # 从手动输入框添加参数 - user_params = self.script_params.text().strip() - if user_params: - arguments.extend(shlex.split(user_params)) - - # Add the file/folder paths at the end - # 在末尾添加文件/文件夹路径 - arguments.extend(paths) - - command = [self.current_script_path] + arguments - - self.console.clear() - self.run_button.setEnabled(False) - self.statusBar().showMessage(lang['status_running']) - self.tabs.setCurrentWidget(self.terminal) # Switch to terminal tab on run / 运行时切换到终端选项卡 - self.progress_bar.hide() - self.progress_label.hide() - self.progress_bar.setValue(0) - self.progress_label.setText("...") - - # Create and start the executor thread - # 创建并启动执行器线程 - self.script_executor = ScriptExecutor(command) - self.script_executor.output_updated.connect(self._append_to_console) # Also send to simple console / 也发送到简单控制台 - self.script_executor.output_updated.connect(self.terminal.append_output) # Send to enhanced terminal / 发送到增强型终端 - self.script_executor.progress_updated.connect(self._update_progress_display) - self.script_executor.process_finished.connect(self._on_script_finished) - self.script_executor.start() - - def _stop_script(self): - """ - Stops the currently running script executor thread. - 停止当前正在运行的脚本执行器线程。 - """ - if self.script_executor and self.script_executor.isRunning(): - lang = UI_TEXTS[self.current_lang] - # 终止进程 - self.script_executor.terminate() - - # 在控制台显示消息 - stop_msg = f"\n--- {lang.get('script_stopped_msg', 'Script execution stopped by user.')} ---\n" - self._append_to_console(stop_msg) - self.terminal.append_output(stop_msg) - - # 手动调用完成函数来重置UI状态 - self._on_script_finished(-1) # 使用-1表示被用户中断 - - def _on_script_finished(self, exit_code): - """ - Handles cleanup and UI updates after the script finishes. - 处理脚本完成后的清理和UI更新。 - """ - lang = UI_TEXTS[self.current_lang] - msg = lang['script_finished_msg'].format(exit_code=exit_code) - self._append_to_console(msg) - self.terminal.append_output(msg) - self.run_button.setEnabled(True) - self.stop_button.setEnabled(False) # 新增:脚本结束后禁用停止按钮 self.statusBar().showMessage(lang['status_ready']) - self.progress_bar.hide() - self.progress_label.hide() - self.script_executor = None - - def _handle_terminal_input(self, text): - """ - Directs user input to the running script or the system shell. - 将用户输入定向到正在运行的脚本或系统shell。 - """ - if self.script_executor and self.script_executor.isRunning(): - self.script_executor.send_input(text) - else: - self._execute_system_command(text) - - def _execute_system_command(self, command_str): - """ - Executes a system command in the terminal if no script is running. - 如果没有脚本正在运行,则在终端中执行系统命令。 - """ - # (NEW) Handle empty Enter press when no script is running - # (新) 处理无脚本运行时按下的空Enter - if not command_str.strip(): - self.terminal.append_output(f"\n{self.terminal.prompt_label.text()} ") - return - - if command_str.lower() == 'exit': - self.close() - return - try: - cmd_parts = shlex.split(command_str) - except ValueError as e: - self.terminal.append_output(f"Error parsing command: {e}\n") - return - if not cmd_parts: - return - - # Handle 'cd' command internally as it affects the GUI's process - # 在内部处理'cd'命令,因为它会影响GUI的进程 - if cmd_parts[0].lower() == 'cd': - if len(cmd_parts) > 1: - try: - os.chdir(cmd_parts[1]) - self.terminal._update_prompt() - except FileNotFoundError: - self.terminal.append_output(f"Error: Directory does not exist: {cmd_parts[1]}\n") - else: - self.terminal.append_output("Error: Please specify a directory\n") - return - - self.system_process = QProcess() - self.system_process.readyReadStandardOutput.connect(self._handle_system_output) - self.system_process.finished.connect(self._on_system_command_finished) - self.system_process.start(cmd_parts[0], cmd_parts[1:]) - if not self.system_process.waitForStarted(): - self.terminal.append_output(f"Error starting command: {cmd_parts[0]}\n") - - def _handle_system_output(self): - """ - Handles output from the system command process. - 处理来自系统命令进程的输出。 - """ - if self.system_process: - output = self.system_process.readAllStandardOutput().data() - try: - decoded_output = output.decode('utf-8') - except UnicodeDecodeError: - decoded_output = output.decode('gbk', errors='replace') - self.terminal.append_output(decoded_output) - - def _on_system_command_finished(self, exit_code): - """ - Handles cleanup after a system command finishes. - 处理系统命令完成后的清理工作。 - """ - self.terminal.append_output(f"\nCommand finished with exit code: {exit_code}\n") - self.system_process = None - self.terminal._update_prompt() - - def closeEvent(self, event): - """ - Ensures all child processes are terminated when the GUI is closed. - 确保在关闭GUI时所有子进程都被终止。 - """ - if self.script_executor and self.script_executor.isRunning(): - self.script_executor.terminate() - if self.system_process and self.system_process.state() == QProcess.ProcessState.Running: - self.system_process.terminate() - event.accept() - - - -def _get_best_font_name(): - """ - Selects a suitable default font based on the operating system. - 根据操作系统选择合适的默认字体。 - """ - system = platform.system() - if system == "Windows": return "Microsoft YaHei" - elif system == "Darwin": return "PingFang SC" - else: return "WenQuanYi Micro Hei" - -if __name__ == "__main__": - UI_TEXTS = load_ui_texts(resource_path("ui_texts.json")) - app = QApplication(sys.argv) - app.setFont(QFont(_get_best_font_name(), 10)) - scripts_directory = resource_path("scripts") - if not os.path.exists(scripts_directory): - os.makedirs(scripts_directory) - window = ScriptGUI(scripts_directory) - window.show() - sys.exit(app.exec()) - - \ No newline at end of file +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Entry point – Toolkit launcher. +入口文件 – 工具箱启动器。 + +Responsibility: initialise the Qt application, load UI texts, and show the +main window. All application logic lives in the sub-packages: + core/ – executor, script registry, i18n, utilities + widgets/ – terminal widget, dynamic-params widget + ui/ – main window +""" + +import os +import platform +import sys + +from PyQt6.QtGui import QFont +from PyQt6.QtWidgets import QApplication + +from core.i18n import load_ui_texts +from core.utils import resource_path +from ui.main_window import ScriptGUI + + +def _get_best_font_name() -> str: + """Return a platform-appropriate CJK-capable font name. + + 返回适合当前平台且支持 CJK 字符的字体名称。 + """ + system = platform.system() + if system == "Windows": + return "Microsoft YaHei" + if system == "Darwin": + return "PingFang SC" + return "WenQuanYi Micro Hei" + + +def main() -> int: + """Bootstrap and run the toolkit application. + + 启动并运行工具箱应用程序。 + """ + # Populate UI_TEXTS before any widget code references it. + # 在任何控件代码引用 UI_TEXTS 之前,先填充其内容。 + load_ui_texts(resource_path("ui_texts.json")) + + app = QApplication(sys.argv) + app.setFont(QFont(_get_best_font_name(), 10)) + + scripts_directory = resource_path("scripts") + if not os.path.exists(scripts_directory): + os.makedirs(scripts_directory) + + window = ScriptGUI(scripts_directory) + window.show() + return app.exec() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..67e55b5 --- /dev/null +++ b/ui/__init__.py @@ -0,0 +1 @@ +# ui package diff --git a/ui/main_window.py b/ui/main_window.py new file mode 100644 index 0000000..aab8ea4 --- /dev/null +++ b/ui/main_window.py @@ -0,0 +1,1117 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +ScriptGUI – the application's main window. +ScriptGUI – 应用程序主窗口。 + +Imports sub-components from: + core.executor – ScriptExecutor + core.script_registry – scan / parse_params / extract_docstring + widgets.terminal – EnhancedTerminalWidget + widgets.dynamic_params – DynamicParamsWidget +""" + +import configparser +import os +import platform +import re +import shlex +import subprocess + +from PyQt6.QtCore import Qt, QProcess +from PyQt6.QtGui import ( + QAction, + QActionGroup, + QColor, + QKeyEvent, + QKeySequence, + QPalette, +) +from PyQt6.QtWidgets import ( + QApplication, + QFileDialog, + QFrame, + QHBoxLayout, + QLabel, + QLineEdit, + QListWidget, + QListWidgetItem, + QMainWindow, + QMenu, + QMessageBox, + QProgressBar, + QPushButton, + QSplitter, + QTabWidget, + QTextEdit, + QVBoxLayout, + QWidget, +) + +from core.executor import ScriptExecutor +from core.i18n import UI_TEXTS +from core.utils import resource_path +import core.script_registry as registry +from widgets.dynamic_params import DynamicParamsWidget +from widgets.terminal import EnhancedTerminalWidget + + +class ScriptGUI(QMainWindow): + """The main application window. + + 主应用程序窗口。 + """ + + def __init__(self, scripts_dir: str) -> None: + """Initialise the main window. + + 初始化主窗口。 + + :param scripts_dir: Path to the directory containing task scripts. + 包含任务脚本的目录路径。 + """ + super().__init__() + + # ------------------------------------------------------------------ + # Configuration + # ------------------------------------------------------------------ + self.config_path = resource_path("config.ini") + self.config = configparser.ConfigParser() + self._load_config() + + # ------------------------------------------------------------------ + # Instance variables + # ------------------------------------------------------------------ + self.scripts_dir = scripts_dir + self.current_script_path: str | None = None + self.current_script_docstring: str = "" + self.script_executor: ScriptExecutor | None = None + self.system_process: QProcess | None = None + self.current_lang: str = "zh" + self.undo_stack: list[list] = [[]] + self.redo_stack: list[list] = [] + + self.original_palette = QApplication.instance().palette() + + # Menu action handles (assigned in _create_menu_bar) + self.light_theme_action: QAction | None = None + self.dark_theme_action: QAction | None = None + self.system_theme_action: QAction | None = None + + # ------------------------------------------------------------------ + # Build UI + # ------------------------------------------------------------------ + self._init_ui() + self._create_actions() + self.load_scripts() + self._update_ui_language() + self._apply_config() + + # ================================================================== + # Configuration persistence + # ================================================================== + + def _load_config(self) -> None: + """Load settings from config.ini, creating it when absent. + + 从 config.ini 加载设置,不存在时自动创建。 + """ + self.config.read(self.config_path, encoding="utf-8") + if not self.config.has_section("Preferences"): + self.config.add_section("Preferences") + + def _save_config(self) -> None: + """Persist the current settings to config.ini. + + 将当前设置持久化到 config.ini。 + """ + with open(self.config_path, "w", encoding="utf-8") as fh: + self.config.write(fh) + + def _apply_config(self) -> None: + """Apply the loaded configuration to the UI. + + 将已加载的配置应用到 UI。 + """ + theme = self.config.get("Preferences", "theme", fallback="system") + action_map = { + "light": self.light_theme_action, + "dark": self.dark_theme_action, + "system": self.system_theme_action, + } + action = action_map.get(theme, self.system_theme_action) + if action: + action.setChecked(True) + self._set_theme(theme, initializing=True) + + # ================================================================== + # Theming + # ================================================================== + + def _set_theme(self, theme_name: str, initializing: bool = False) -> None: + """Apply a colour theme and optionally persist the preference. + + 应用颜色主题,并可选地持久化该偏好。 + + :param theme_name: ``'light'``, ``'dark'``, or ``'system'``. + :param initializing: ``True`` during startup – skips writing config. + 启动期间为 True,跳过写入配置。 + """ + app = QApplication.instance() + if theme_name == "dark": + dark_palette = QPalette() + dark_palette.setColor(QPalette.ColorRole.Window, QColor(53, 53, 53)) + dark_palette.setColor(QPalette.ColorRole.WindowText, Qt.GlobalColor.white) + dark_palette.setColor(QPalette.ColorRole.Base, QColor(25, 25, 25)) + dark_palette.setColor(QPalette.ColorRole.AlternateBase, QColor(53, 53, 53)) + dark_palette.setColor(QPalette.ColorRole.ToolTipBase, Qt.GlobalColor.white) + dark_palette.setColor(QPalette.ColorRole.ToolTipText, Qt.GlobalColor.white) + dark_palette.setColor(QPalette.ColorRole.Text, Qt.GlobalColor.white) + dark_palette.setColor(QPalette.ColorRole.Button, QColor(53, 53, 53)) + dark_palette.setColor(QPalette.ColorRole.ButtonText, Qt.GlobalColor.white) + dark_palette.setColor(QPalette.ColorRole.BrightText, Qt.GlobalColor.red) + dark_palette.setColor(QPalette.ColorRole.Link, QColor(42, 130, 218)) + dark_palette.setColor(QPalette.ColorRole.Highlight, QColor(42, 130, 218)) + dark_palette.setColor( + QPalette.ColorRole.HighlightedText, Qt.GlobalColor.black + ) + app.setPalette(dark_palette) + else: + app.setPalette(self.original_palette) + + if not initializing: + self.config.set("Preferences", "theme", theme_name) + self._save_config() + + self._update_widget_styles() + + def _update_widget_styles(self) -> None: + """Re-apply custom stylesheets to all theme-aware widgets. + + This is the single source of truth for theme-dependent styles. + + 将自定义样式表重新应用于所有需要感知主题的控件。 + 这是依赖主题样式的唯一事实来源。 + """ + is_dark = self.palette().color(QPalette.ColorRole.Window).lightness() < 128 + highlight_color = self.palette().color(QPalette.ColorRole.Highlight).name() + highlighted_text_color = ( + self.palette().color(QPalette.ColorRole.HighlightedText).name() + if is_dark + else "#ffffff" + ) + + list_style = f""" + QListWidget {{ outline: 0; }} + QListWidget::item {{ padding: 4px; }} + QListWidget::item:selected {{ + background-color: {highlight_color}; + color: {highlighted_text_color}; + border-radius: 4px; + }} + """ + self.path_list_widget.setStyleSheet(list_style) + self.script_list.setStyleSheet(list_style) + self.terminal.apply_theme() + + # ================================================================== + # UI construction + # ================================================================== + + def _init_ui(self) -> None: + """Initialise the overall UI layout. + + 初始化整体 UI 布局。 + """ + self._create_menu_bar() + self.setGeometry(100, 100, 1200, 800) + central_widget = QWidget() + self.setCentralWidget(central_widget) + self.setAcceptDrops(True) + + main_layout = QHBoxLayout(central_widget) + main_splitter = QSplitter(Qt.Orientation.Horizontal) + main_layout.addWidget(main_splitter) + + main_splitter.addWidget(self._create_left_panel()) + main_splitter.addWidget(self._create_right_panel()) + main_splitter.setSizes([400, 800]) + + self.statusBar() + self.setStyleSheet( + "QPushButton { min-height: 30px; padding: 5px; }" + " QLineEdit, QComboBox { min-height: 28px; }" + ) + self._update_widget_styles() + + def _create_menu_bar(self) -> None: + """Create the main menu bar with theme-switching actions. + + 创建带有主题切换动作的主菜单栏。 + """ + self.menu_bar = self.menuBar() + lang = UI_TEXTS[self.current_lang] + + self.prefs_menu = self.menu_bar.addMenu(lang["prefs_menu"]) + self.theme_menu = self.prefs_menu.addMenu(lang["theme_menu"]) + + theme_group = QActionGroup(self) + theme_group.setExclusive(True) + + self.light_theme_action = QAction(lang["theme_light"], self, checkable=True) + self.light_theme_action.triggered.connect(lambda: self._set_theme("light")) + + self.dark_theme_action = QAction(lang["theme_dark"], self, checkable=True) + self.dark_theme_action.triggered.connect(lambda: self._set_theme("dark")) + + self.system_theme_action = QAction(lang["theme_system"], self, checkable=True) + self.system_theme_action.triggered.connect(lambda: self._set_theme("system")) + self.system_theme_action.setChecked(True) + + for action in ( + self.light_theme_action, + self.dark_theme_action, + self.system_theme_action, + ): + theme_group.addAction(action) + self.theme_menu.addAction(action) + + def _create_left_panel(self) -> QWidget: + """Create the left panel: script list + description. + + 创建左侧面板:脚本列表 + 描述区。 + """ + panel = QWidget() + layout = QVBoxLayout(panel) + + header_layout = QHBoxLayout() + self.script_list_label = QLabel() + header_layout.addWidget(self.script_list_label) + header_layout.addStretch() + self.refresh_button = QPushButton("🔄") + self.refresh_button.clicked.connect(self._refresh_scripts) + self.refresh_button.setFixedSize(30, 30) + header_layout.addWidget(self.refresh_button) + layout.addLayout(header_layout) + + self.script_list = QListWidget() + self.script_list.setAlternatingRowColors(True) + self.script_list.itemClicked.connect(self._on_script_selected) + self.script_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.script_list.customContextMenuRequested.connect( + self._show_script_context_menu + ) + + self.script_info_label = QLabel() + self.script_info = QTextEdit() + self.script_info.setReadOnly(True) + self.switch_lang_button = QPushButton() + self.switch_lang_button.clicked.connect(self._toggle_language) + + layout.addWidget(self.script_list) + layout.addWidget(self.script_info_label) + layout.addWidget(self.script_info) + layout.addWidget(self.switch_lang_button) + return panel + + def _create_right_panel(self) -> QWidget: + """Create the right panel: file list, params, run controls, output tabs. + + 创建右侧面板:文件列表、参数区、运行控制区、输出选项卡。 + """ + panel = QWidget() + layout = QVBoxLayout(panel) + + # File list -------------------------------------------------- + self.path_list_label = QLabel() + self.path_list_widget = QListWidget() + self.path_list_widget.setSelectionMode( + QListWidget.SelectionMode.ExtendedSelection + ) + self.path_list_widget.itemChanged.connect(self._on_path_item_changed) + self.path_list_widget.itemDoubleClicked.connect( + self.path_list_widget.editItem + ) + layout.addWidget(self.path_list_label) + layout.addWidget(self.path_list_widget) + + # File-management buttons ------------------------------------ + button_layout = QHBoxLayout() + self.browse_files_button = QPushButton() + self.browse_files_button.clicked.connect(self._browse_files) + self.browse_dir_button = QPushButton() + self.browse_dir_button.clicked.connect(self._browse_directories) + self.select_all_button = QPushButton() + self.select_all_button.clicked.connect(self._on_select_all_button_clicked) + self.remove_button = QPushButton() + self.remove_button.clicked.connect(self._on_remove_button_clicked) + + button_layout.addWidget(self.browse_files_button) + button_layout.addWidget(self.browse_dir_button) + button_layout.addStretch() + button_layout.addWidget(self.select_all_button) + button_layout.addWidget(self.remove_button) + layout.addLayout(button_layout) + + # Dynamic params widget -------------------------------------- + self.dynamic_params = DynamicParamsWidget() + layout.addWidget(self.dynamic_params) + + # Separator -------------------------------------------------- + line = QFrame() + line.setFrameShape(QFrame.Shape.HLine) + line.setFrameShadow(QFrame.Shadow.Sunken) + layout.addWidget(line) + + # Manual params input ---------------------------------------- + params_layout = QHBoxLayout() + self.params_label = QLabel() + self.script_params = QLineEdit() + params_layout.addWidget(self.params_label) + params_layout.addWidget(self.script_params) + layout.addLayout(params_layout) + + # Run / Stop buttons ----------------------------------------- + run_stop_layout = QHBoxLayout() + run_stop_layout.setSpacing(10) + + self.run_button = QPushButton() + self.run_button.clicked.connect(self._run_script) + self.run_button.setEnabled(False) + self.run_button.setStyleSheet( + "QPushButton { font-weight: bold; font-size: 14pt;" + " padding: 8px; min-height: 40px; }" + ) + + self.stop_button = QPushButton() + self.stop_button.clicked.connect(self._stop_script) + self.stop_button.setEnabled(False) + self.stop_button.setStyleSheet( + "QPushButton { font-weight: bold; font-size: 14pt;" + " padding: 8px; min-height: 40px; color: #D32F2F; }" + ) + + run_stop_layout.addWidget(self.run_button) + run_stop_layout.addWidget(self.stop_button) + layout.addLayout(run_stop_layout) + + # Progress bar ----------------------------------------------- + self.progress_bar = QProgressBar() + self.progress_bar.setTextVisible(True) + self.progress_bar.setRange(0, 100) + self.progress_bar.setValue(0) + self.progress_bar.hide() + + self.progress_label = QLabel("...") + self.progress_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.progress_label.hide() + + layout.addWidget(self.progress_bar) + layout.addWidget(self.progress_label) + + # Output tabs ------------------------------------------------ + self.output_label = QLabel() + self.tabs = QTabWidget() + + self.console = QTextEdit() + self.console.setReadOnly(True) + self.console.setStyleSheet( + "background-color: #282c34; color: #abb2bf;" + " font-family: Consolas, 'Courier New', monospace;" + ) + + self.terminal = EnhancedTerminalWidget() + self.terminal.command_entered.connect(self._handle_terminal_input) + + self.tabs.addTab(self.console, "") + self.tabs.addTab(self.terminal, "") + layout.addWidget(self.output_label) + layout.addWidget(self.tabs) + + return panel + + def _create_actions(self) -> None: + """Create global keyboard shortcuts (Undo / Redo). + + 创建全局键盘快捷键(撤销/重做)。 + """ + self.undo_action = QAction("Undo", self) + self.undo_action.setShortcut(QKeySequence("Ctrl+Z")) + self.undo_action.triggered.connect(self.undo) + self.addAction(self.undo_action) + + self.redo_action = QAction("Redo", self) + if platform.system() == "Darwin": + self.redo_action.setShortcut(QKeySequence("Ctrl+Shift+Z")) + else: + self.redo_action.setShortcut(QKeySequence("Ctrl+Y")) + self.redo_action.triggered.connect(self.redo) + self.addAction(self.redo_action) + + # ================================================================== + # Language / i18n + # ================================================================== + + def _update_ui_language(self) -> None: + """Refresh all UI text labels to match the current language. + + 将所有 UI 文字标签刷新为当前语言。 + """ + lang = UI_TEXTS[self.current_lang] + self.setWindowTitle(lang["window_title"]) + self.script_list_label.setText(lang["available_scripts"]) + self.script_info_label.setText(lang["script_info"]) + self.path_list_label.setText(lang["path_list_label"]) + self.run_button.setText(lang["run_button"]) + self.stop_button.setText(lang.get("stop_button", "Stop Script")) + self.browse_files_button.setText(lang["browse_files_button"]) + self.browse_dir_button.setText(lang["browse_dir_button"]) + self.params_label.setText(lang["params_label"]) + self.script_params.setPlaceholderText(lang["params_placeholder"]) + self.output_label.setText(lang["output_label"]) + self.tabs.setTabText(0, lang["stdout_tab"]) + self.tabs.setTabText(1, lang["terminal_tab"]) + self.statusBar().showMessage(lang["status_ready"]) + self.switch_lang_button.setText(lang["switch_lang_button"]) + self.refresh_button.setToolTip(lang["refresh_button_tooltip"]) + self.terminal.set_language(self.current_lang) + self._update_script_list_display() + self._update_remove_button_state() + self._update_script_info_display() + self._update_select_all_button_state() + + if hasattr(self, "prefs_menu"): + self.prefs_menu.setTitle(lang["prefs_menu"]) + self.theme_menu.setTitle(lang["theme_menu"]) + self.light_theme_action.setText(lang["theme_light"]) + self.dark_theme_action.setText(lang["theme_dark"]) + self.system_theme_action.setText(lang["theme_system"]) + + def _toggle_language(self) -> None: + """Toggle the UI language between Chinese and English. + + 在中文与英文之间切换 UI 语言。 + """ + self.current_lang = "en" if self.current_lang == "zh" else "zh" + self._update_ui_language() + + # ================================================================== + # Script list management + # ================================================================== + + def _refresh_scripts(self) -> None: + """Reload the script list from disk. + + 从磁盘重新加载脚本列表。 + """ + self.script_list.clear() + self.script_info.clear() + self.current_script_path = None + self.run_button.setEnabled(False) + self.dynamic_params.clear() + self.load_scripts() + self.statusBar().showMessage(UI_TEXTS[self.current_lang]["status_ready"], 2000) + + def load_scripts(self) -> None: + """Scan the scripts directory and populate the script list widget. + + 扫描脚本目录并填充脚本列表控件。 + """ + for info in registry.scan(self.scripts_dir): + item = QListWidgetItem() + item.setData(Qt.ItemDataRole.UserRole, info) + self.script_list.addItem(item) + self._update_script_list_display() + + def _update_script_list_display(self) -> None: + """Update all list-item display texts to the current language. + + 将所有列表项的显示文字更新为当前语言。 + """ + for i in range(self.script_list.count()): + item = self.script_list.item(i) + data = item.data(Qt.ItemDataRole.UserRole) + if data: + display_name = ( + data["name_zh"] if self.current_lang == "zh" else data["name_en"] + ) + item.setText(display_name) + + def _show_script_context_menu(self, position) -> None: + """Show a context menu with 'Show in folder' for the right-clicked script. + + 为右键点击的脚本显示包含「在文件夹中显示」的上下文菜单。 + """ + item = self.script_list.itemAt(position) + if not item: + return + script_data = item.data(Qt.ItemDataRole.UserRole) + script_path = script_data.get("path") + if not script_path: + return + + context_menu = QMenu(self) + title_action = QAction(os.path.basename(script_path), self) + title_action.setEnabled(False) + context_menu.addAction(title_action) + context_menu.addSeparator() + + show_action = QAction("在文件夹中显示", self) + show_action.triggered.connect( + lambda: self._open_in_file_explorer(script_path) + ) + context_menu.addAction(show_action) + context_menu.exec(self.script_list.mapToGlobal(position)) + + def _open_in_file_explorer(self, path: str) -> None: + """Open the system file explorer and highlight *path*. + + 打开系统文件资源管理器并高亮 *path*。 + """ + if not os.path.exists(path): + return + system = platform.system() + try: + if system == "Windows": + subprocess.run(["explorer", "/select,", os.path.normpath(path)]) + elif system == "Darwin": + subprocess.run(["open", "-R", path]) + else: + subprocess.run(["xdg-open", os.path.dirname(path)]) + except Exception as exc: # noqa: BLE001 + QMessageBox.information( + self, + "File Path", + f"Could not open explorer.\nThe file is located at:\n{path}\n({exc})", + ) + + # ================================================================== + # Script selection & parameter parsing + # ================================================================== + + def _on_script_selected(self, item: QListWidgetItem) -> None: + """Handle a script being selected from the list. + + 处理从列表中选择脚本的事件。 + """ + script_data = item.data(Qt.ItemDataRole.UserRole) + self.current_script_path = script_data["path"] + try: + with open(self.current_script_path, "r", encoding="utf-8") as fh: + content = fh.read() + self.current_script_docstring = registry.extract_docstring(content) + self.run_button.setEnabled(True) + params = registry.parse_params(self.current_script_path) + self.dynamic_params.build_ui(params, self.current_lang) + except Exception as exc: # noqa: BLE001 + self.current_script_docstring = ( + f"{UI_TEXTS[self.current_lang]['info_read_error']}{exc}" + ) + self.run_button.setEnabled(False) + self.dynamic_params.clear() + self._update_script_info_display() + + def _update_script_info_display(self) -> None: + """Show the relevant language block of the script's docstring. + + 显示脚本文档字符串中对应语言的部分。 + Uses ``~~~`` as the primary language separator. + 使用 ``~~~`` 作为语言分隔符。 + """ + doc = self.current_script_docstring + display_text = doc if doc else UI_TEXTS[self.current_lang]["info_no_docstring"] + + if "~~~" in doc: + try: + parts = doc.split("~~~", 1) + chinese_doc = parts[0] + english_doc = parts[1] if len(parts) > 1 else "" + selected_doc = chinese_doc if self.current_lang == "zh" else english_doc + display_text = re.sub( + r"\[display-name-..\](.*?)\n", "", selected_doc, count=2 + ).strip() + except Exception as exc: # noqa: BLE001 + print(f"Error parsing docstring with '~~~': {exc}") + display_text = doc + + self.script_info.setText(display_text) + + # ================================================================== + # File / path list management + # ================================================================== + + def dragEnterEvent(self, event) -> None: # type: ignore[override] + """Accept drag events that carry file URLs. + + 接受携带文件 URL 的拖拽事件。 + """ + if event.mimeData().hasUrls(): + event.acceptProposedAction() + + def dropEvent(self, event) -> None: # type: ignore[override] + """Add dropped files/directories to the path list. + + 将拖放的文件/目录添加到路径列表。 + """ + self._save_state_for_undo() + for url in event.mimeData().urls(): + self._add_path_to_list(url.toLocalFile()) + self._update_remove_button_state() + + def _add_path_to_list(self, path: str) -> None: + """Add a single path item with checkbox and editable flags. + + 添加带复选框和可编辑标志的单个路径项。 + """ + if os.path.exists(path): + item = QListWidgetItem(path) + item.setFlags( + item.flags() + | Qt.ItemFlag.ItemIsUserCheckable + | Qt.ItemFlag.ItemIsEditable + ) + item.setCheckState(Qt.CheckState.Unchecked) + self.path_list_widget.addItem(item) + + def _browse_files(self) -> None: + """Open a file-picker dialog and add selected files. + + 打开文件选择对话框并添加所选文件。 + """ + self._save_state_for_undo() + files, _ = QFileDialog.getOpenFileNames( + self, UI_TEXTS[self.current_lang]["browse_files_button"] + ) + if files: + for f in files: + self._add_path_to_list(f) + self._update_remove_button_state() + + def _browse_directories(self) -> None: + """Open a directory-picker dialog and add selected directories. + + 打开目录选择对话框并添加所选目录。 + """ + self._save_state_for_undo() + dialog = QFileDialog(self, UI_TEXTS[self.current_lang]["browse_dir_button"]) + dialog.setFileMode(QFileDialog.FileMode.Directory) + dialog.setOption(QFileDialog.Option.ShowDirsOnly, True) + if dialog.exec(): + directories = dialog.selectedFiles() + if directories: + for d in directories: + self._add_path_to_list(d) + self._update_remove_button_state() + + def _on_path_item_changed(self, item: QListWidgetItem) -> None: + """Save undo state when an item's text or check state changes. + + 当项目文字或勾选状态变化时保存撤销状态。 + """ + self._save_state_for_undo() + self._update_remove_button_state() + self._update_select_all_button_state() + + def _get_checked_items(self) -> list[QListWidgetItem]: + """Return all checked items in the path list. + + 返回路径列表中所有被勾选的项目。 + """ + return [ + self.path_list_widget.item(i) + for i in range(self.path_list_widget.count()) + if self.path_list_widget.item(i).checkState() == Qt.CheckState.Checked + ] + + def _get_union_of_marked_items(self) -> list[QListWidgetItem]: + """Return the union of selected (highlighted) and checked items. + + 返回被选中(高亮)与被勾选项目的并集。 + ``QListWidgetItem`` is not hashable, so we build the list manually. + ``QListWidgetItem`` 不可哈希,因此手动构建列表。 + """ + combined = self.path_list_widget.selectedItems() + self._get_checked_items() + unique: list[QListWidgetItem] = [] + for item in combined: + if item not in unique: + unique.append(item) + return unique + + def _on_remove_button_clicked(self) -> None: + """Remove the union of selected and checked items, or clear all. + + 移除选中与勾选项目的并集;若无标记项目则清空全部。 + """ + self._save_state_for_undo() + items_to_delete = self._get_union_of_marked_items() + if items_to_delete: + for item in items_to_delete: + self.path_list_widget.takeItem(self.path_list_widget.row(item)) + else: + self.path_list_widget.clear() + self._update_remove_button_state() + self._update_select_all_button_state() + + def _update_remove_button_state(self) -> None: + """Update the remove button label based on whether any items are marked. + + 根据是否有标记项目更新移除按钮的文字。 + """ + lang = UI_TEXTS[self.current_lang] + is_any_marked = bool( + self.path_list_widget.selectedItems() or self._get_checked_items() + ) + self.remove_button.setText( + lang["remove_selected_button"] if is_any_marked else lang["remove_all_button"] + ) + + def _update_select_all_button_state(self) -> None: + """Update the Select All / Deselect All button label. + + 更新「勾选全部/取消选择」按钮的文字。 + """ + lang = UI_TEXTS[self.current_lang] + self.select_all_button.setText( + lang["deselect_all"] if self._get_checked_items() else lang["select_all"] + ) + + def _on_select_all_button_clicked(self) -> None: + """Toggle check state on all path list items. + + 切换所有路径列表项的勾选状态。 + """ + self._save_state_for_undo() + new_state = ( + Qt.CheckState.Unchecked + if self._get_checked_items() + else Qt.CheckState.Checked + ) + for i in range(self.path_list_widget.count()): + self.path_list_widget.item(i).setCheckState(new_state) + self._update_select_all_button_state() + + # ================================================================== + # Undo / Redo + # ================================================================== + + def _save_state_for_undo(self) -> None: + """Push the current path-list state onto the undo stack. + + 将当前路径列表状态压入撤销栈。 + """ + state = [ + { + "text": self.path_list_widget.item(i).text(), + "checked": self.path_list_widget.item(i).checkState() + == Qt.CheckState.Checked, + } + for i in range(self.path_list_widget.count()) + ] + if not self.undo_stack or state != self.undo_stack[-1]: + self.undo_stack.append(state) + self.redo_stack.clear() + + def _restore_state(self, state: list) -> None: + """Restore the path list from a saved state dict. + + 从已保存的状态字典中恢复路径列表。 + """ + self.path_list_widget.clear() + for item_data in state: + item = QListWidgetItem(item_data["text"]) + item.setFlags( + item.flags() + | Qt.ItemFlag.ItemIsUserCheckable + | Qt.ItemFlag.ItemIsEditable + ) + item.setCheckState( + Qt.CheckState.Checked if item_data["checked"] else Qt.CheckState.Unchecked + ) + self.path_list_widget.addItem(item) + self._update_remove_button_state() + self._update_select_all_button_state() + + def undo(self) -> None: + """Undo the last path-list modification. + + 撤销最近一次路径列表修改。 + """ + if len(self.undo_stack) > 1: + self.redo_stack.append(self.undo_stack.pop()) + self._restore_state(self.undo_stack[-1]) + else: + self.statusBar().showMessage( + UI_TEXTS[self.current_lang]["undo_stack_empty"], 2000 + ) + + def redo(self) -> None: + """Redo the last undone path-list modification. + + 重做最近一次撤销的路径列表修改。 + """ + if self.redo_stack: + state_to_restore = self.redo_stack.pop() + self.undo_stack.append(state_to_restore) + self._restore_state(state_to_restore) + else: + self.statusBar().showMessage( + UI_TEXTS[self.current_lang]["redo_stack_empty"], 2000 + ) + + # ================================================================== + # Key-press handling + # ================================================================== + + def keyPressEvent(self, event: QKeyEvent) -> None: + """Handle global shortcuts: Esc unchecks all; Delete/Backspace removes items. + + 处理全局快捷键:Esc 取消勾选;Delete/Backspace 移除项目。 + """ + if event.key() == Qt.Key.Key_Escape: + changed = False + for item in self._get_checked_items(): + item.setCheckState(Qt.CheckState.Unchecked) + changed = True + if changed: + self._save_state_for_undo() + self._update_select_all_button_state() + return + + if self.path_list_widget.hasFocus() and event.key() in ( + Qt.Key.Key_Delete, + Qt.Key.Key_Backspace, + ): + items_to_delete = self._get_union_of_marked_items() + if items_to_delete: + self._save_state_for_undo() + for item in items_to_delete: + self.path_list_widget.takeItem(self.path_list_widget.row(item)) + self._update_remove_button_state() + self._update_select_all_button_state() + return + + super().keyPressEvent(event) + + # ================================================================== + # Script execution + # ================================================================== + + def _run_script(self) -> None: + """Assemble the command and launch the ScriptExecutor thread. + + 组装命令并启动 ScriptExecutor 线程。 + """ + lang = UI_TEXTS[self.current_lang] + if not self.current_script_path: + QMessageBox.warning( + self, lang["warn_select_script_title"], lang["warn_select_script_msg"] + ) + return + + checked_items = self._get_checked_items() + if checked_items: + paths = [item.text() for item in checked_items] + else: + paths = [ + self.path_list_widget.item(i).text() + for i in range(self.path_list_widget.count()) + ] + + if not paths: + QMessageBox.warning( + self, lang["warn_no_paths_title"], lang["warn_no_paths_msg"] + ) + return + + arguments = ["--gui-mode", "--lang", self.current_lang] + arguments.extend(self.dynamic_params.build_args()) + + user_params = self.script_params.text().strip() + if user_params: + arguments.extend(shlex.split(user_params)) + + arguments.extend(paths) + command = [self.current_script_path] + arguments + + self.console.clear() + self.run_button.setEnabled(False) + self.stop_button.setEnabled(True) + self.statusBar().showMessage(lang["status_running"]) + self.tabs.setCurrentWidget(self.terminal) + self.progress_bar.hide() + self.progress_label.hide() + self.progress_bar.setValue(0) + self.progress_label.setText("...") + + self.script_executor = ScriptExecutor(command) + self.script_executor.output_updated.connect(self._append_to_console) + self.script_executor.output_updated.connect(self.terminal.append_output) + self.script_executor.progress_updated.connect(self._update_progress_display) + self.script_executor.process_finished.connect(self._on_script_finished) + self.script_executor.start() + + def _stop_script(self) -> None: + """Terminate the currently running script. + + 终止当前正在运行的脚本。 + """ + if self.script_executor and self.script_executor.isRunning(): + lang = UI_TEXTS[self.current_lang] + self.script_executor.terminate() + stop_msg = ( + f"\n--- {lang.get('script_stopped_msg', 'Script execution stopped by user.')} ---\n" + ) + self._append_to_console(stop_msg) + self.terminal.append_output(stop_msg) + self._on_script_finished(-1) + + def _on_script_finished(self, exit_code: int) -> None: + """Clean up and reset the UI after the script finishes. + + 脚本结束后执行清理并重置 UI。 + """ + lang = UI_TEXTS[self.current_lang] + msg = lang["script_finished_msg"].format(exit_code=exit_code) + self._append_to_console(msg) + self.terminal.append_output(msg) + self.run_button.setEnabled(True) + self.stop_button.setEnabled(False) + self.statusBar().showMessage(lang["status_ready"]) + self.progress_bar.hide() + self.progress_label.hide() + self.script_executor = None + + def _append_to_console(self, text: str) -> None: + """Append *text* to the Standard Output tab. + + 将 *text* 追加到标准输出选项卡。 + """ + from PyQt6.QtGui import QTextCursor + + cursor = self.console.textCursor() + cursor.movePosition(QTextCursor.MoveOperation.End) + self.console.setTextCursor(cursor) + self.console.insertPlainText(text) + self.console.ensureCursorVisible() + + def _update_progress_display( + self, current_float: float, max_float: float, description: str + ) -> None: + """Update the progress bar from float progress values. + + 从浮点进度值更新进度条。 + Maps floats to an integer scale to smooth out fractional steps. + 将浮点数映射到整数刻度以平滑小数步长。 + """ + if self.progress_bar.isHidden(): + self.progress_bar.show() + self.progress_label.show() + max_int = int(max_float * 100) + current_int = int(current_float * 100) + self.progress_bar.setRange(0, max_int) + self.progress_bar.setValue(current_int) + self.progress_label.setText(description) + + # ================================================================== + # Terminal / system-command handling + # ================================================================== + + def _handle_terminal_input(self, text: str) -> None: + """Route user input to the running script or the system shell. + + 将用户输入路由到正在运行的脚本或系统 shell。 + """ + if self.script_executor and self.script_executor.isRunning(): + self.script_executor.send_input(text) + else: + self._execute_system_command(text) + + def _execute_system_command(self, command_str: str) -> None: + """Execute a system command when no script is running. + + 无脚本运行时执行系统命令。 + """ + if not command_str.strip(): + self.terminal.append_output( + f"\n{self.terminal.prompt_label.text()} " + ) + return + + if command_str.lower() == "exit": + self.close() + return + + try: + cmd_parts = shlex.split(command_str) + except ValueError as exc: + self.terminal.append_output(f"Error parsing command: {exc}\n") + return + + if not cmd_parts: + return + + if cmd_parts[0].lower() == "cd": + if len(cmd_parts) > 1: + try: + os.chdir(cmd_parts[1]) + self.terminal._update_prompt() + except FileNotFoundError: + self.terminal.append_output( + f"Error: Directory does not exist: {cmd_parts[1]}\n" + ) + else: + self.terminal.append_output("Error: Please specify a directory\n") + return + + self.system_process = QProcess() + self.system_process.readyReadStandardOutput.connect( + self._handle_system_output + ) + self.system_process.finished.connect(self._on_system_command_finished) + self.system_process.start(cmd_parts[0], cmd_parts[1:]) + if not self.system_process.waitForStarted(): + self.terminal.append_output( + f"Error starting command: {cmd_parts[0]}\n" + ) + + def _handle_system_output(self) -> None: + """Forward system-command stdout to the terminal widget. + + 将系统命令的标准输出转发到终端控件。 + """ + if self.system_process: + raw = self.system_process.readAllStandardOutput().data() + try: + decoded = raw.decode("utf-8") + except UnicodeDecodeError: + decoded = raw.decode("gbk", errors="replace") + self.terminal.append_output(decoded) + + def _on_system_command_finished(self, exit_code: int) -> None: + """Clean up after a system command finishes. + + 系统命令结束后执行清理。 + """ + self.terminal.append_output( + f"\nCommand finished with exit code: {exit_code}\n" + ) + self.system_process = None + self.terminal._update_prompt() + + # ================================================================== + # Window close + # ================================================================== + + def closeEvent(self, event) -> None: # type: ignore[override] + """Terminate all child processes before the window closes. + + 窗口关闭前终止所有子进程。 + """ + if self.script_executor and self.script_executor.isRunning(): + self.script_executor.terminate() + if ( + self.system_process + and self.system_process.state() == QProcess.ProcessState.Running + ): + self.system_process.terminate() + event.accept() diff --git a/widgets/__init__.py b/widgets/__init__.py new file mode 100644 index 0000000..c8f9844 --- /dev/null +++ b/widgets/__init__.py @@ -0,0 +1 @@ +# widgets package diff --git a/widgets/dynamic_params.py b/widgets/dynamic_params.py new file mode 100644 index 0000000..4456f3d --- /dev/null +++ b/widgets/dynamic_params.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +DynamicParamsWidget – builds a grid of input widgets from a parameter list +and serialises them back to command-line arguments. +DynamicParamsWidget – 根据参数列表构建输入控件网格,并将其序列化为命令行参数。 +""" + +import re +from typing import Any + +from PyQt6.QtWidgets import ( + QCheckBox, + QComboBox, + QGridLayout, + QLabel, + QLineEdit, + QWidget, +) + + +class DynamicParamsWidget(QWidget): + """Container that generates and manages dynamic parameter input widgets. + + 管理动态参数输入控件的容器控件。 + + Public API + ---------- + build_ui(params, lang) – populate the grid from a parameter list + build_args() – return a flat list of CLI argument strings + clear() – remove all generated widgets + """ + + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self._layout = QGridLayout(self) + self._layout.setContentsMargins(0, 10, 0, 10) + self._layout.setSpacing(10) + self._param_widgets: list[QWidget] = [] + self.setVisible(False) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def build_ui(self, params: list[dict[str, Any]], lang: str) -> None: + """Create widgets for *params* and make the panel visible. + + 根据 *params* 创建控件并显示面板。 + + :param params: List of parameter dicts (from ``script_registry.parse_params``). + 参数字典列表(来自 script_registry.parse_params)。 + :param lang: Current UI language code (``'zh'`` or ``'en'``). + 当前 UI 语言代码('zh' 或 'en')。 + """ + self.clear() + if not params: + return + + params = sorted(params, key=lambda x: x["name"]) + + row, col, max_rows = 0, 0, 3 + for param in params: + name = param["name"] + p_type = param.get("type", "flag") + p_default = param.get("default") + p_help = param.get("help", "") + + widget: QWidget | None = None + + if p_type == "choice": + label = QLabel(f"{name}:") + combo = QComboBox() + + # Parse optional display-name map from the help string. + # 从 help 字符串中解析可选的显示名称映射。 + # Format: [display: value=zh_name,en_name | ...] + display_map: dict[str, dict[str, str]] = {} + display_match = re.search(r"\[display:\s*(.*?)\]", p_help) + if display_match: + for entry in display_match.group(1).split("|"): + try: + value, names = entry.split("=", 1) + zh_name, en_name = names.split(",", 1) + display_map[value.strip()] = { + "zh": zh_name.strip(), + "en": en_name.strip(), + } + except ValueError: + continue + + for choice_value in param.get("choices", []): + display_name = choice_value + if choice_value in display_map: + display_name = display_map[choice_value].get(lang, choice_value) + combo.addItem(display_name, userData=choice_value) + + if p_default: + idx = combo.findData(p_default) + if idx != -1: + combo.setCurrentIndex(idx) + + widget = combo + self._layout.addWidget(label, row, col * 2) + self._layout.addWidget(widget, row, col * 2 + 1) + + elif p_type == "value": + label = QLabel(f"{name}:") + line_edit = QLineEdit() + if p_default: + line_edit.setText(p_default) + widget = line_edit + self._layout.addWidget(label, row, col * 2) + self._layout.addWidget(widget, row, col * 2 + 1) + + else: # 'flag' + checkbox = QCheckBox(name) + if p_default: + checkbox.setChecked(True) + widget = checkbox + self._layout.addWidget(widget, row, col * 2, 1, 2) + + if widget is not None: + # Use the plain-text part of the help string as the tooltip. + # 使用 help 字符串的纯文本部分作为工具提示。 + widget.setToolTip(p_help.split("[display:")[0].strip()) + widget.setProperty("param_name", name) + widget.setProperty("param_type", p_type) + self._param_widgets.append(widget) + + row += 1 + if row >= max_rows: + row = 0 + col += 1 + + self.setVisible(True) + self._apply_max_widths() + + def build_args(self) -> list[str]: + """Serialise the current widget values to a flat CLI argument list. + + 将当前控件值序列化为扁平 CLI 参数列表。 + """ + args: list[str] = [] + for widget in self._param_widgets: + p_name: str = widget.property("param_name") + p_type: str = widget.property("param_type") + + if p_type == "flag" and isinstance(widget, QCheckBox) and widget.isChecked(): + args.append(p_name) + elif p_type == "choice" and isinstance(widget, QComboBox): + internal_value = widget.currentData() + args.extend([p_name, internal_value]) + elif p_type == "value" and isinstance(widget, QLineEdit): + value = widget.text() + if value: + args.extend([p_name, value]) + return args + + def clear(self) -> None: + """Remove all dynamically generated widgets and hide the panel. + + 移除所有动态生成的控件并隐藏面板。 + """ + while self._layout.count(): + item = self._layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + self._param_widgets = [] + self.setVisible(False) + + # ------------------------------------------------------------------ + # Qt overrides + # ------------------------------------------------------------------ + + def resizeEvent(self, event) -> None: # type: ignore[override] + """Keep each param widget to at most 1/3 of the panel width. + + 将每个参数控件的最大宽度限制为面板宽度的 1/3。 + """ + self._apply_max_widths() + super().resizeEvent(event) + + # ------------------------------------------------------------------ + # Private helpers + # ------------------------------------------------------------------ + + def _apply_max_widths(self) -> None: + total_width = self.width() + max_param_width = max(int(total_width / 3), 180) + for widget in self._param_widgets: + widget.setMaximumWidth(max_param_width) diff --git a/widgets/terminal.py b/widgets/terminal.py new file mode 100644 index 0000000..934a7e2 --- /dev/null +++ b/widgets/terminal.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +EnhancedTerminalWidget – a terminal-style widget that displays script output +and accepts interactive input. +EnhancedTerminalWidget – 显示脚本输出并接受交互输入的终端风格控件。 +""" + +import os + +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtGui import QKeyEvent, QPalette, QTextCursor +from PyQt6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QTextEdit, QVBoxLayout, QWidget + +from core.i18n import UI_TEXTS + + +class EnhancedTerminalWidget(QWidget): + """A widget that emulates a basic terminal for script output and input. + + 模拟基本终端的控件,用于脚本输出与交互输入。 + """ + + # Emitted when the user presses Enter in the input line. + # 用户在输入行按下 Enter 时发出。 + command_entered = pyqtSignal(str) + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.history: list[str] = [] + self.history_index: int = 0 + self.current_lang: str = "zh" + self._init_ui() + + # ------------------------------------------------------------------ + # Setup + # ------------------------------------------------------------------ + + def _init_ui(self) -> None: + """Build the widget's internal layout. + + 构建控件内部布局。 + """ + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + self.output_display = QTextEdit() + self.output_display.setReadOnly(True) + + input_layout = QHBoxLayout() + self.prompt_label = QLabel("$") + self.prompt_label.setStyleSheet( + "color: #61afef; font-weight: bold; font-size: 12pt;" + ) + self.command_input = QLineEdit() + self.command_input.returnPressed.connect(self._on_command_entered) + + input_layout.addWidget(self.prompt_label) + input_layout.addWidget(self.command_input) + + layout.addWidget(self.output_display) + layout.addLayout(input_layout) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def set_language(self, lang: str) -> None: + """Update the terminal's language and reset the welcome message. + + 更新终端语言并重置欢迎信息。 + """ + self.current_lang = lang + self.output_display.clear() + self.append_output(UI_TEXTS[self.current_lang]["terminal_welcome"]) + self._update_prompt() + + def append_output(self, text: str) -> None: + """Append *text* to the display, handling ``\\r`` for in-place updates. + + 将 *text* 追加到显示区,智能处理 ``\\r`` 以支持进度条等原地更新。 + """ + cursor = self.output_display.textCursor() + cursor.movePosition(QTextCursor.MoveOperation.End) + self.output_display.setTextCursor(cursor) + + lines = text.replace("\r\n", "\n").split("\n") + for line in lines: + if not line: + continue + if line.endswith("\r"): + cursor.movePosition(QTextCursor.MoveOperation.StartOfLine) + cursor.movePosition( + QTextCursor.MoveOperation.EndOfLine, + QTextCursor.MoveMode.KeepAnchor, + ) + cursor.removeSelectedText() + cursor.insertText(line[:-1]) + else: + cursor.insertText(line + "\n") + + self.output_display.ensureCursorVisible() + + def apply_theme(self) -> None: + """Re-apply theme-aware stylesheets to the terminal sub-widgets. + + 重新将感知主题的样式表应用于终端子控件。 + """ + is_dark = self.palette().color(QPalette.ColorRole.Window).lightness() < 128 + if is_dark: + self.output_display.setStyleSheet( + "background-color: #282c34; color: #abb2bf;" + " font-family: Consolas, 'Courier New', monospace; font-size: 11pt;" + ) + self.prompt_label.setStyleSheet( + "color: #61afef; font-weight: bold; font-size: 12pt;" + ) + self.command_input.setStyleSheet( + "background-color: #282c34; color: #e06c75;" + " font-family: Consolas, 'Courier New', monospace;" + " font-size: 11pt; border: none;" + ) + else: + base_color = self.palette().color(QPalette.ColorRole.Base).name() + text_color = self.palette().color(QPalette.ColorRole.Text).name() + highlight_color = self.palette().color(QPalette.ColorRole.Highlight).name() + self.output_display.setStyleSheet( + f"background-color: {base_color}; color: {text_color};" + " font-family: Consolas, 'Courier New', monospace; font-size: 11pt;" + ) + self.prompt_label.setStyleSheet( + f"color: {highlight_color}; font-weight: bold; font-size: 12pt;" + ) + self.command_input.setStyleSheet( + f"background-color: {base_color}; color: #d12f2f;" + " font-family: Consolas, 'Courier New', monospace;" + " font-size: 11pt; border: none;" + ) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _update_prompt(self) -> None: + """Update the prompt label to show the current working directory. + + 更新提示符标签以显示当前工作目录。 + """ + self.prompt_label.setText(f"[{os.path.basename(os.getcwd())}]$") + + def _on_command_entered(self) -> None: + """Handle Enter key press – emit signal and clear the input field. + + 处理 Enter 键按下事件 – 发出信号并清空输入框。 + """ + command = self.command_input.text() + if command.strip(): + self.history.append(command.strip()) + self.history_index = len(self.history) + self.command_entered.emit(command) + self.command_input.clear() + + # ------------------------------------------------------------------ + # Event overrides + # ------------------------------------------------------------------ + + def keyPressEvent(self, event: QKeyEvent) -> None: + """Navigate command history with Up/Down arrow keys. + + 使用上/下方向键浏览命令历史。 + """ + if event.key() == Qt.Key.Key_Up and self.history and self.history_index > 0: + self.history_index -= 1 + self.command_input.setText(self.history[self.history_index]) + elif event.key() == Qt.Key.Key_Down: + if self.history and self.history_index < len(self.history) - 1: + self.history_index += 1 + self.command_input.setText(self.history[self.history_index]) + else: + self.history_index = len(self.history) + self.command_input.clear() + else: + super().keyPressEvent(event) From 76f84fa64d68156efbc38cb4c6287df386279da1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Mar 2026 12:18:26 +0000 Subject: [PATCH 3/3] Fix code review comments: use slice offset, proper QResizeEvent type, remove local import, extract timeout constant Co-authored-by: UltraAce258 <174670272+UltraAce258@users.noreply.github.com> --- core/executor.py | 2 +- core/script_registry.py | 6 +++++- ui/main_window.py | 3 +-- widgets/dynamic_params.py | 3 ++- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/core/executor.py b/core/executor.py index 1572e28..e9f4716 100644 --- a/core/executor.py +++ b/core/executor.py @@ -106,7 +106,7 @@ def _handle_output(self) -> None: continue if line.startswith("[PROGRESS]"): try: - payload = line[len("[PROGRESS]"):].strip() + payload = line[10:].strip() # len("[PROGRESS]") == 10 progress_part, description = payload.split("|", 1) current_str, max_str = progress_part.split("/", 1) current = float(current_str.strip()) diff --git a/core/script_registry.py b/core/script_registry.py index 3e6e72f..3e52b35 100644 --- a/core/script_registry.py +++ b/core/script_registry.py @@ -39,6 +39,10 @@ import sys from typing import Any +# Maximum seconds to wait for a script's --gui-schema response. +# 等待脚本 --gui-schema 响应的最长秒数。 +_GUI_SCHEMA_TIMEOUT_SECONDS = 10 + # ------------------------------------------------------------------ # Docstring extraction @@ -141,7 +145,7 @@ def _try_gui_schema(script_path: str) -> list[dict[str, Any]] | None: text=True, encoding="utf-8", errors="replace", - timeout=10, + timeout=_GUI_SCHEMA_TIMEOUT_SECONDS, ) if result.returncode != 0: return None diff --git a/ui/main_window.py b/ui/main_window.py index aab8ea4..2c7d329 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -26,6 +26,7 @@ QKeyEvent, QKeySequence, QPalette, + QTextCursor, ) from PyQt6.QtWidgets import ( QApplication, @@ -986,8 +987,6 @@ def _append_to_console(self, text: str) -> None: 将 *text* 追加到标准输出选项卡。 """ - from PyQt6.QtGui import QTextCursor - cursor = self.console.textCursor() cursor.movePosition(QTextCursor.MoveOperation.End) self.console.setTextCursor(cursor) diff --git a/widgets/dynamic_params.py b/widgets/dynamic_params.py index 4456f3d..853bcd5 100644 --- a/widgets/dynamic_params.py +++ b/widgets/dynamic_params.py @@ -9,6 +9,7 @@ import re from typing import Any +from PyQt6.QtGui import QResizeEvent from PyQt6.QtWidgets import ( QCheckBox, QComboBox, @@ -173,7 +174,7 @@ def clear(self) -> None: # Qt overrides # ------------------------------------------------------------------ - def resizeEvent(self, event) -> None: # type: ignore[override] + def resizeEvent(self, event: QResizeEvent) -> None: """Keep each param widget to at most 1/3 of the panel width. 将每个参数控件的最大宽度限制为面板宽度的 1/3。