diff --git a/CLAUDE.md b/CLAUDE.md index ae4b67f..4e89d0c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,10 +46,10 @@ uv run build_pyinstaller.py 构建产物输出到 `build/` 目录。 ### CI/CD 触发 -提交信息包含特定前缀时自动触发 GitHub Actions 构建: +提交信息包含特定前缀时自动触发 GitHub Actions 构建(在 `main` 和 `dev` 分支): - `build:` - 同时触发 PyInstaller 和 Nuitka 构建 -- `build_0:` - 仅 PyInstaller 构建 -- `build_1:` - 仅 Nuitka 构建 +- `build_0:` - 仅 PyInstaller 构建(输出到 `build/PyBuilder/`) +- `build_1:` - 仅 Nuitka 构建(输出到 `build/main.dist/`) **示例**: ```bash @@ -58,7 +58,7 @@ git commit -m "build_0: 修复界面问题" # 仅 PyInstaller git commit -m "build_1: 性能优化" # 仅 Nuitka ``` -构建产物在 GitHub Actions → Artifacts 下载,保留 7 天。 +构建产物在 GitHub Actions → Artifacts 下载,保留 7 天。详见 `.github/workflows/README.md`。 ## 架构设计 @@ -106,10 +106,15 @@ git commit -m "build_1: 性能优化" # 仅 Nuitka - `load_config()` / `save_config()` - 配置的加载和保存 **`src/widgets/option_builders.py`** -- UI 组件工厂函数:`create_switch_widget()`, `create_input_widget()`, `create_button_row()` +- UI 组件工厂函数:`create_switch_widget()`, `create_input_widget()`, `create_button_row()`, `create_switch_row()`, `create_inputs_row()` - `build_nuitka_options()` / `build_pyinstaller_options()` - 构建标签页组件 - 可复用的 UI 布局生成器,减少重复代码 +**`src/widgets/figlet_widget.py`** +- `FigletWidget` - 使用 pyfiglet 生成 ASCII 艺术文本的 Widget,支持 60+ 种字体 +- `AnimatedFiglet` - 支持颜色样式的 Figlet Widget +- 兼容 Textual 7.x,提供 `set_text()` / `set_font()` / `set_color()` 动态更新方法 + ### 主题系统 应用支持 8 种主题,通过 F1-F8 快捷键切换: - F1: textual-dark (默认) @@ -152,43 +157,55 @@ Nuitka 使用官方推荐的 `--mode` 参数: PyBuild-Generate/ ├── main.py # 程序启动入口 ├── build_*.py # 本项目的构建脚本 +├── pyproject.toml # 项目配置、依赖定义 ├── src/ -│ ├── __main__.py # 模块入口 +│ ├── __main__.py # 模块入口(定义 main() 函数) │ ├── __init__.py # 包初始化(定义 __version__, __author__, __repo__) │ ├── app.py # PyBuildTUI 主应用类 │ ├── screens/ # TUI 屏幕模块(12个) -│ │ ├── welcome_screen.py -│ │ ├── project_selector_screen.py -│ │ ├── mode_selector_screen.py -│ │ ├── compiler_selector_screen.py -│ │ ├── compile_config_screen.py -│ │ ├── package_options_screen.py -│ │ ├── plugin_selector_screen.py -│ │ ├── installer_config_screen.py -│ │ ├── installer_options_screen.py -│ │ ├── installer_generation_screen.py -│ │ ├── generation_screen.py -│ │ └── help_screen.py +│ │ └── __init__.py # 所有屏幕类的统一导出 │ ├── widgets/ # 可复用 UI 组件 -│ │ └── option_builders.py # UI 组件工厂函数 +│ │ ├── option_builders.py # UI 组件工厂函数 +│ │ ├── figlet_widget.py # ASCII 艺术文本组件 +│ │ └── __init__.py # 所有组件的统一导出 │ └── utils/ # 工具模块 -│ ├── build_config.py # 构建配置管理 -│ ├── script_generator.py # 构建脚本生成 -│ ├── installer_generator.py # 安装包脚本生成 -│ ├── config.py # 应用配置管理 -│ └── terminal.py # 终端工具 +│ ├── build_config.py # 构建配置管理 +│ ├── script_generator.py # 构建脚本生成 +│ ├── installer_generator.py # 安装包脚本生成 +│ ├── config.py # 应用配置管理 +│ └── terminal.py # 终端工具 ├── assets/ # 资源文件(字体、图标、文档) ├── .github/workflows/ # CI/CD 配置 └── config.yaml # 应用配置(运行时生成) ``` +### 模块导出约定 +- `src/screens/__init__.py` 导出所有屏幕类,方便 `from src.screens import WelcomeScreen` +- `src/widgets/__init__.py` 导出所有 UI 组件工厂函数和类 +- 添加新屏幕/组件时,务必在对应的 `__init__.py` 中添加导出 + ## 开发注意事项 +### 脚本生成器设计模式 +`src/utils/script_generator.py` 使用**模板函数模式**生成构建脚本: +- `_generate_script_header()` - 生成通用头部(imports、Color类) +- `_generate_config_section()` - 生成配置常量 +- `_generate_build_function_header()` - 生成 build() 函数开头 +- `_generate_build_execution()` - 生成命令执行部分 +- `_generate_build_result()` - 生成结果处理和错误处理 +- `_generate_main_block()` - 生成 `if __name__ == '__main__'` 入口 + +这种设计确保: +1. Nuitka 和 PyInstaller 脚本共享相同的代码结构 +2. 生成的脚本风格统一、易于维护 +3. 修改一处即可影响两种构建工具的生成结果 + ### 代码组织原则 - **模块化**:每个屏幕模块职责单一,不超过 600 行 -- **可复用组件**:通用 UI 组件抽取到 `src/widgets/option_builders.py` -- **配置驱动**:所有构建参数通过 `DEFAULT_BUILD_CONFIG` ��一定义 -- **异步操作**:配置加载/保存使用异步函数避免阻塞 UI +- **可复用组件**:通用 UI 组件抽取到 `src/widgets/` 目录 +- **配置驱动**:所有构建参数通过 `DEFAULT_BUILD_CONFIG` 统一定义 +- **异步操作**:配置加载/保存使用异步函数(`async_load_build_config`/`async_save_build_config`)避免阻塞 UI +- **预编译正则**:`script_generator.py` 使用预编译的正则表达式 `_SPLIT_PATTERN` 提升性能 ### 添加新的构建参数 1. 更新 `DEFAULT_BUILD_CONFIG` in `src/utils/build_config.py` @@ -197,24 +214,38 @@ PyBuild-Generate/ ### 添加新屏幕 1. 在 `src/screens/` 创建新文件,继承自 `textual.widgets.Screen` -2. 在 `src/screens/__init__.py` 中导出 +2. 在 `src/screens/__init__.py` 中导出新屏幕类 3. 使用 `self.app.push_screen()` 或 `self.app.pop_screen()` 导航 +4. ESC 键已全局绑定为 `app.pop_screen()`,无需重复绑定 + +### 添加新 UI 组件 +1. 在 `src/widgets/` 创建新文件 +2. 在 `src/widgets/__init__.py` 中导出 +3. 确保组件兼容 Textual 7.x ### 数据共享模式 屏幕间共享数据通过 `self.app` 属性: -- `self.app.project_dir` - 当前选中的项目目录 -- `self.app.build_mode` - 构建模式(simple/full/expert) -- `self.app.config` - 应用级配置字典(主题、终端尺寸限制) +- `self.app.project_dir: Path | None` - 当前选中的项目目录 +- `self.app.build_mode: str | None` - 构建模式 +- `self.app.config: dict` - 应用级配置字典(主题、终端尺寸限制) -**注意**: `build_config`(项目构建配置)存储在项目目录的 `build_config.yaml` 中,通过 `load_build_config()` / `save_build_config()` 函数操作,不作为 `self.app` 属性。 +**注意**: `build_config`(项目构建配置)存储在项目目录的 `build_config.yaml` 中,通过 `load_build_config()` / `save_build_config()` / `async_load_build_config()` / `async_save_build_config()` 函数操作,不作为 `self.app` 属性。 ### 配置文件格式 - `config.yaml` - 应用级配置(键: 值) - `build_config.yaml` - 项目级配置,支持注释、列表(用 `- ` 前缀) +### 配置加载类型兼容 +`build_config.py` 的 `load_build_config()` 函数实现了智能类型转换: +- 布尔值兼容:`"true"/"yes"/"1"` → `True`,`"false"/"no"/"0"` → `False` +- 列表兼容:逗号分隔的字符串 → `List[str]` +- 整型兼容:数字字符串 → `int` +- 这确保从 YAML 加载的配置与 `DEFAULT_BUILD_CONFIG` 类型一致 + ### Python 版本要求 -- 运行本工具: Python >= 3.12 -- 生成的脚本: Python >= 3.6(推荐 3.8+),使用 f-string 语法 +- **运行本工具**: Python >= 3.12 +- **生成脚本目标环境**: Python >= 3.6(推荐 3.8+),使用 f-string 语法 +- **Textual 版本**: 项目使用 Textual 7.x,确保自定义组件兼容此版本 ## 调试和测试 @@ -243,3 +274,9 @@ cat build_config.yaml uv run main.py # 选择项目目录 → 查看配置是否回显正确 ``` + +### 常见问题排查 +- **构建脚本生成的命令不符合预期**:检查 `script_generator.py` 中对应参数的生成逻辑 +- **配置未保存**:确认使用 `async_save_build_config()` 异步函数,或检查文件权限 +- **主题未持久化**:检查 `config.py` 的 `save_config()` 是否正常执行 +- **屏幕导航异常**:确认新屏幕已在 `src/screens/__init__.py` 中导出 diff --git a/README.md b/README.md index d6dfd26..0f6da4c 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,6 @@ uv run build_pyinstaller.py | `F8` | textual-light 主题 | | `ESC` | 返回上一步 | | `Ctrl+C` | 退出程序 | -| `Ctrl+S` | 保存配置 | @@ -141,12 +140,18 @@ git commit -m "build_1: 更新" # 触发 Nuitka 构建 PyBuild-Generate/ ├── main.py # 程序入口 ├── build_*.py # 构建脚本 +├── pyproject.toml # 项目配置与依赖 ├── src/ -│ ├── app.py # 主应用 -│ ├── screens/ # 12个界面屏幕 +│ ├── __main__.py # 模块入口 +│ ├── __init__.py # 包初始化 +│ ├── app.py # PyBuildTUI 主应用类 +│ ├── screens/ # 12个 TUI 界面屏幕 +│ ├── widgets/ # 可复用 UI 组件 │ └── utils/ # 工具模块 ├── .github/workflows/ # CI/CD 配置 └── assets/ # 资源文件 + ├── img/ # 截图 + └── pyfiglet/fonts/ # ASCII 字体 ``` ## 依赖 @@ -154,7 +159,7 @@ PyBuild-Generate/ ### 运行本工具需要 - Python >= 3.12 -- textual >= 6.12.0 +- textual >= 7.0.0 - pyfiglet >= 1.0.4 - loguru >= 0.7.3 - pyyaml >= 6.0 diff --git a/docs/Nuitka-Parameters.md b/docs/Nuitka-Parameters.md index 831650f..7cd2f5c 100644 --- a/docs/Nuitka-Parameters.md +++ b/docs/Nuitka-Parameters.md @@ -1,7 +1,7 @@ # Nuitka 编译参数完整列表 > 基于 Nuitka 2.8+ 版本 -> 最后更新:2024年12月 +> 最后更新:2026年1月 --- @@ -9,28 +9,42 @@ - ✅ **已添加** - 项目已支持的参数 - 🔧 **可添加** - 官方支持但项目尚未实现的参数 +- ⚠️ **已弃用** - 仍可使用但即将触发警告,建议迁移 - 📦 **商业版** - 需要 Nuitka Commercial 才能使用 --- ## 基础编译模式 -### ✅ --standalone +### ✅ --mode=MODE(推荐) **状态**:已添加 +**说明**:设置编译模式(Nuitka 2.0+ 推荐方式) +**可选值**: +- `accelerated`:加速模式(生成 .pyd/.so) +- `standalone`:独立目录模式 +- `onefile`:单文件模式 +- `app` / `app-dist`:macOS 应用包 +- `module` / `package`:模块/包编译 + +**项目实现**:自动从 standalone/onefile 配置推导,或直接指定 mode + +### ⚠️ --standalone(已弃用) +**状态**:已弃用(Nuitka 2.8+) **说明**:创建独立可执行文件,包含所有依赖 -**使用场景**:发布应用给没有 Python 环境的用户 -**项目实现**:在打包选项中配置 +**迁移指南**:使用 `--mode=standalone` 替代 +**注意**:此参数即将开始触发警告,项目已自动转换为 `--mode` -### ✅ --onefile -**状态**:已添加 +### ⚠️ --onefile(已弃用) +**状态**:已弃用(Nuitka 2.8+) **说明**:将所有内容打包到单个可执行文件 -**使用场景**:需要单文件分发的应用 -**项目实现**:在打包选项中配置 +**迁移指南**:使用 `--mode=onefile` 替代 +**注意**:此参数即将开始触发警告,项目已自动转换为 `--mode` ### 🔧 --module **状态**:可添加 **说明**:编译为 Python 扩展模块(.pyd/.so) **使用场景**:加速关键模块,保持 Python 导入方式 +**迁移指南**:建议使用 `--mode=module` **建议优先级**:中 --- @@ -43,11 +57,11 @@ **默认值**:当前目录 **项目实现**:build_config.yaml 中的 output_dir -### 🔧 --output-filename=NAME -**状态**:可添加 +### ✅ --output-filename=NAME +**状态**:已添加 **说明**:自定义输出文件名 **使用场景**:控制最终可执行文件的名称 -**建议优先级**:高 +**项目实现**:project_name 配置 ### ✅ --remove-output **状态**:已添加 @@ -131,14 +145,8 @@ ## 控制台与窗口 -### ✅ --disable-console +### ✅ --windows-console-mode=MODE(推荐) **状态**:已添加 -**说明**:禁用控制台窗口(GUI 应用) -**平台**:Windows -**项目实现**:通过 show_console 反向控制 - -### 🔧 --windows-console-mode=MODE -**状态**:可添加 **说明**:精细控制 Windows 控制台行为 **可选值**: - `force`:强制显示(默认) @@ -146,7 +154,14 @@ - `attach`:附加到现有控制台 - `hide`:创建但隐藏 -**建议优先级**:高 +**项目实现**:通过 show_console 配置(false 时使用 `disable`) + +### ⚠️ --disable-console(已弃用) +**状态**:已弃用 +**说明**:禁用控制台窗口(GUI 应用) +**平台**:Windows +**迁移指南**:使用 `--windows-console-mode=disable` 替代 +**注意**:项目已自动使用新参数 --- @@ -203,18 +218,19 @@ ## 数据文件 -### 🔧 --include-data-files=SRC=DST -**状态**:可添加 +### ✅ --include-data-files=SRC=DST +**状态**:已添加 **说明**:包含特定数据文件 **格式**:`source_path=dest_path` **示例**:`--include-data-files=config.json=config.json` -**建议优先级**:高 +**项目实现**:include_data_files 配置 -### 🔧 --include-data-dir=DIRECTORY -**状态**:可添加 +### ✅ --include-data-dir=SRC=DST +**状态**:已添加 **说明**:包含整个数据目录 -**示例**:`--include-data-dir=assets/` -**建议优先级**:高 +**格式**:`source_dir=dest_dir` +**示例**:`--include-data-dir=assets/=assets/` +**项目实现**:include_data_dirs 配置 ### 🔧 --include-package-data=PACKAGE **状态**:可添加 @@ -232,27 +248,27 @@ ## 模块控制 -### 🔧 --include-module=MODULE -**状态**:可添加 +### ✅ --include-module=MODULE +**状态**:已添加 **说明**:强制包含特定模块 **使用场景**:动态导入的模块 -**建议优先级**:高 +**项目实现**:include_modules 配置 -### 🔧 --include-package=PACKAGE -**状态**:可添加 +### ✅ --include-package=PACKAGE +**状态**:已添加 **说明**:包含整个包及其子模块 -**建议优先级**:高 +**项目实现**:include_packages 配置 -### 🔧 --nofollow-import-to=MODULE -**状态**:可添加 +### ✅ --nofollow-import-to=MODULE +**状态**:已添加 **说明**:不跟踪特定模块的导入 **使用场景**:排除可选依赖 -**建议优先级**:中 +**项目实现**:nofollow_imports 配置 -### ✅ --nofollow-imports -**状态**:部分支持 -**说明**:通过 exclude_packages 实现类似功能 -**项目实现**:exclude_packages 列表 +### ✅ --follow-imports +**状态**:已添加 +**说明**:跟踪所有导入(默认启用) +**项目实现**:follow_imports 配置 --- @@ -462,25 +478,21 @@ ## 推荐的下一步添加 ### 优先级:高 -1. ✅ `--output-filename` - 自定义输出名称 -2. ✅ `--include-data-files` - 数据文件包含 -3. ✅ `--include-data-dir` - 目录包含 -4. ✅ `--include-module` - 模块包含 -5. ✅ `--windows-console-mode` - 精细控制台控制 -6. ✅ `--macos-create-app-bundle` - macOS 应用支持 -7. ✅ `--report` - 编译报告生成 +1. 🔧 `--macos-create-app-bundle` - macOS 应用支持 +2. 🔧 `--report` - 编译报告生成 +3. 🔧 `--include-package-data` - 包数据文件 +4. 🔧 `--gcc` - GCC 编译器支持 ### 优先级:中 -1. ✅ `--include-package` - 包含完整包 -2. ✅ `--static-libpython` - 静态链接 -3. ✅ `--windows-product-name` - 产品信息 -4. ✅ `--macos-sign-identity` - macOS 签名 -5. ✅ `--verbose` - 详细日志 +1. 🔧 `--static-libpython` - 静态链接 +2. 🔧 `--windows-product-name` - 产品信息 +3. 🔧 `--macos-sign-identity` - macOS 签名 +4. 🔧 `--verbose` - 详细日志 ### 优先级:低 -1. ✅ `--show-modules` - 模块列表 -2. ✅ `--show-scons` - 构建命令 -3. ✅ `--plugin-list` - 插件列表 +1. 🔧 `--show-modules` - 模块列表 +2. 🔧 `--show-scons` - 构建命令 +3. 🔧 `--plugin-list` - 插件列表 --- diff --git a/docs/PyInstaller-Parameters.md b/docs/PyInstaller-Parameters.md index d05b007..2c9890d 100644 --- a/docs/PyInstaller-Parameters.md +++ b/docs/PyInstaller-Parameters.md @@ -1,7 +1,7 @@ # PyInstaller 编译参数完整列表 -> 基于 PyInstaller 6.17.0 版本 -> 最后更新:2024年12月 +> 基于 PyInstaller 6.18.0 版本 +> 最后更新:2026年1月 --- @@ -9,10 +9,22 @@ - ✅ **已添加** - 项目已支持的参数 - 🔧 **可添加** - 官方支持但项目尚未实现的参数 +- ❌ **已移除** - 已从 PyInstaller 中移除的参数 +- ⚠️ **即将弃用** - v7.0 中将被移除或阻止的功能 - **特定场景** - 仅在特定场景下使用 --- +## ⚠️ 即将弃用警告(PyInstaller v7.0) + +以下行为将在 PyInstaller 7.0 中被阻止: +1. **onefile + macOS .app bundles**:onefile 模式与 macOS 应用包的组合将被阻止 +2. **提升权限运行**:使用 sudo 或管理员权限运行 PyInstaller 将被阻止 +3. **site-packages 路径**:将 `site-packages` 目录添加到 `pathex`/`--paths` 将被阻止 +4. **`-m` 简写**:`--manifest` 的 `-m` 简写将被移除 + +--- + ## 基础打包模式 ### ✅ --onedir @@ -358,15 +370,15 @@ **说明**:允许远程桌面 UAC 提升 **项目实现**:可通过 win_manifest 实现 -### 🔧 --win-private-assemblies -**状态**:可添加 -**说明**:使用私有程序集 -**建议优先级**:低 +### ❌ --win-private-assemblies(已移除) +**状态**:已移除(PyInstaller 6.0) +**说明**:WinSxS 程序集支持已在 PyInstaller 6.0 中移除 +**注意**:此参数不再有效,请勿使用 -### 🔧 --win-no-prefer-redirects -**状态**:可添加 -**说明**:不优先使用重定向程序集 -**建议优先级**:低 +### ❌ --win-no-prefer-redirects(已移除) +**状态**:已移除(PyInstaller 6.0) +**说明**:WinSxS 程序集支持已在 PyInstaller 6.0 中移除 +**注意**:此参数不再有效,请勿使用 --- diff --git a/pyproject.toml b/pyproject.toml index 69b8612..a119b2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,11 +10,11 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [ "loguru>=0.7.3", - "nuitka>=2.8.9", + "nuitka>=2.8.10", "pyfiglet>=1.0.4", - "pyinstaller>=6.17.0", + "pyinstaller>=6.18.0", "pyyaml>=6.0", - "textual>=6.12.0", + "textual>=7.4.0", ] [project.scripts] diff --git a/src/screens/__init__.py b/src/screens/__init__.py index 3c6502d..16b1865 100644 --- a/src/screens/__init__.py +++ b/src/screens/__init__.py @@ -2,6 +2,7 @@ 屏幕模块 """ +from src.screens.base_config_screen import BaseConfigScreen from src.screens.welcome_screen import WelcomeScreen from src.screens.project_selector_screen import ProjectSelectorScreen from src.screens.mode_selector_screen import ModeSelectorScreen @@ -16,6 +17,7 @@ from src.screens.help_screen import HelpScreen __all__ = [ + "BaseConfigScreen", "WelcomeScreen", "ProjectSelectorScreen", "ModeSelectorScreen", diff --git a/src/screens/base_config_screen.py b/src/screens/base_config_screen.py new file mode 100644 index 0000000..dc9908c --- /dev/null +++ b/src/screens/base_config_screen.py @@ -0,0 +1,140 @@ +""" +配置屏幕基类 +提供配置加载、保存、验证的通用逻辑 +""" + +import asyncio +from pathlib import Path +from typing import Any + +from textual.screen import Screen +from textual.binding import Binding + +from src.utils import ( + async_load_build_config, + async_save_build_config, + validate_build_config, +) + + +class BaseConfigScreen(Screen): + """配置屏幕基类 + + 提供以下通用功能: + - 项目目录初始化和验证 + - 配置的异步加载和保存 + - 配置验证 + - 返回上一屏、保存配置等通用操作 + + 子类需要实现: + - compose(): 创建界面组件 + - _load_config_to_ui(): 将配置加载到UI + - _save_config_from_ui(): 从UI保存配置到内存 + + 子类可以重写: + - _validate_config(): 自定义验证逻辑 + - _on_config_loaded(): 配置加载后的额外处理 + """ + + BINDINGS = [ + Binding("escape", "back", "返回"), + Binding("ctrl+s", "save", "保存"), + ] + + def __init__(self) -> None: + super().__init__() + self.config: dict[str, Any] = {} + self.project_dir: Path | None = None + + async def on_mount(self) -> None: + """挂载时加载配置""" + if not self._init_project_dir(): + return + + # 异步加载现有配置或使用默认配置 + self.config = await async_load_build_config(self.project_dir) # type: ignore[arg-type] + self._load_config_to_ui() + self._on_config_loaded() + + def _init_project_dir(self) -> bool: + """初始化项目目录 + + Returns: + bool: 初始化是否成功 + """ + # 使用 getattr 确保类型安全 + project_dir = getattr(self.app, "project_dir", None) + if not project_dir: + self.app.notify("未选择项目目录", severity="error") + self.app.pop_screen() + return False + self.project_dir = project_dir + return True + + def _load_config_to_ui(self) -> None: + """将配置加载到UI(子类必须实现)""" + raise NotImplementedError("子类必须实现 _load_config_to_ui 方法") + + def _save_config_from_ui(self) -> None: + """从UI保存配置到内存(子类必须实现)""" + raise NotImplementedError("子类必须实现 _save_config_from_ui 方法") + + def _on_config_loaded(self) -> None: + """配置加载后的额外处理(子类可重写)""" + pass + + def _validate_config(self) -> tuple[bool, str]: + """验证配置 + + Returns: + tuple[bool, str]: (是否有效, 错误信息) + """ + return validate_build_config(self.config, self.project_dir) # type: ignore[arg-type] + + def _validate_and_save(self) -> bool: + """验证并保存配置到内存 + + Returns: + bool: 验证是否成功 + """ + self._save_config_from_ui() + + is_valid, error_msg = self._validate_config() + if not is_valid: + self.app.notify(f"配置验证失败: {error_msg}", severity="error") + return False + + return True + + async def _async_save_config(self) -> bool: + """异步保存配置到文件 + + Returns: + bool: 保存是否成功 + """ + success = await async_save_build_config(self.project_dir, self.config) # type: ignore[arg-type] + if not success: + self.app.notify("配置保存失败", severity="error") + return success + + async def _async_save_notify(self) -> None: + """异步保存并显示通知""" + success = await self._async_save_config() + if success: + self.app.notify("配置已保存", severity="information") + + def action_back(self) -> None: + """返回上一屏""" + self.app.pop_screen() + + def action_save(self) -> None: + """保存配置(同步触发异步保存)""" + if self._validate_and_save(): + asyncio.create_task(self._async_save_notify()) + + async def action_save_async(self) -> None: + """异步保存配置""" + if self._validate_and_save(): + success = await self._async_save_config() + if success: + self.app.notify("配置已保存", severity="information") diff --git a/src/screens/compile_config_screen.py b/src/screens/compile_config_screen.py index e637ba8..1660ae3 100644 --- a/src/screens/compile_config_screen.py +++ b/src/screens/compile_config_screen.py @@ -3,43 +3,26 @@ 用于配置 PyInstaller 和 Nuitka 的编译选项 """ -import asyncio from pathlib import Path from textual.app import ComposeResult -from textual.screen import Screen from textual.containers import Container, Vertical, Horizontal from textual.widgets import Static, Button, Input, Select, Label -from textual.binding import Binding -from src.utils import ( - load_build_config, - async_load_build_config, - async_save_build_config, - validate_build_config, -) +from src.screens.base_config_screen import BaseConfigScreen +from src.utils import load_build_config -class CompileConfigScreen(Screen): +class CompileConfigScreen(BaseConfigScreen): """编译配置屏幕""" CSS_PATH = Path(__file__).parent.parent / "style" / "compile_config_screen.tcss" - BINDINGS = [ - Binding("escape", "back", "返回"), - Binding("ctrl+s", "save", "保存"), - ] - - def __init__(self): - super().__init__() - self.config = {} - self.project_dir: Path | None = None - def compose(self) -> ComposeResult: """创建界面组件""" with Container(id="config-container"): yield Static("编译配置", id="screen-title") - # 显示项目信息 + # 显示项目信息(compose 时 project_dir 尚未初始化,使用 getattr) project_dir = getattr(self.app, "project_dir", None) if project_dir: yield Static(f"项目: {project_dir}", id="project-info") @@ -119,18 +102,6 @@ def compose(self) -> ComposeResult: yield Button("保存配置", variant="primary", id="save-btn", flat=True) yield Button("下一步", variant="success", id="next-btn", flat=True) - async def on_mount(self) -> None: - """挂载时加载配置""" - self.project_dir = self.app.project_dir # type: ignore[assignment] - if not self.project_dir: - self.app.notify("未选择项目目录", severity="error") - self.app.pop_screen() - return - - # 异步加载现有配置或使用默认配置 - self.config = await async_load_build_config(self.project_dir) - self._load_config_to_ui() - def _load_config_to_ui(self) -> None: """将配置加载到UI""" # 基本信息 @@ -195,43 +166,9 @@ async def on_button_pressed(self, event: Button.Pressed) -> None: elif button_id == "save-btn": self.action_save() elif button_id == "next-btn": - await self.action_next() - - def action_back(self) -> None: - """返回上一屏""" - self.app.pop_screen() - - def _validate_and_save(self) -> bool: - """验证并保存配置,返回是否成功""" - self._save_config_from_ui() - - # 验证配置 - is_valid, error_msg = validate_build_config(self.config, self.project_dir) # type: ignore[arg-type] - if not is_valid: - self.app.notify(f"配置验证失败: {error_msg}", severity="error") - return False - - return True - - async def _async_save_config(self) -> bool: - """异步保存配置到文件""" - success = await async_save_build_config(self.project_dir, self.config) # type: ignore[arg-type] - if not success: - self.app.notify("配置保存失败", severity="error") - return success - - def action_save(self) -> None: - """保存配置""" - if self._validate_and_save(): - asyncio.create_task(self._async_save_notify()) - - async def _async_save_notify(self) -> None: - """异步保存并显示通知""" - success = await self._async_save_config() - if success: - self.app.notify("配置已保存", severity="information") - - async def action_next(self) -> None: + await self._action_next() + + async def _action_next(self) -> None: """进入下一步:打包选项配置""" if not self._validate_and_save(): return diff --git a/src/screens/installer_config_screen.py b/src/screens/installer_config_screen.py index 58a9529..34bccc8 100644 --- a/src/screens/installer_config_screen.py +++ b/src/screens/installer_config_screen.py @@ -6,33 +6,18 @@ import asyncio from pathlib import Path from textual.app import ComposeResult -from textual.screen import Screen from textual.containers import Container, Vertical, Horizontal from textual.widgets import Static, Button, Input, Label, Select -from textual.binding import Binding -from src.utils import ( - load_build_config, - async_load_build_config, - async_save_build_config, -) +from src.screens.base_config_screen import BaseConfigScreen +from src.utils import load_build_config -class InstallerConfigScreen(Screen): +class InstallerConfigScreen(BaseConfigScreen): """安装包配置屏幕 - 通用信息""" CSS_PATH = Path(__file__).parent.parent / "style" / "compile_config_screen.tcss" - BINDINGS = [ - Binding("escape", "back", "返回"), - Binding("ctrl+s", "save", "保存"), - ] - - def __init__(self): - super().__init__() - self.config = {} - self.project_dir: Path | None = None - def compose(self) -> ComposeResult: """创建界面组件""" with Container(id="config-container"): @@ -119,17 +104,6 @@ def compose(self) -> ComposeResult: yield Button("保存配置", variant="primary", id="save-btn", flat=True) yield Button("下一步", variant="success", id="next-btn", flat=True) - async def on_mount(self) -> None: - """挂载时加载配置""" - self.project_dir = self.app.project_dir # type: ignore[assignment] - if not self.project_dir: - self.app.notify("未选择项目目录", severity="error") - self.app.pop_screen() - return - - self.config = await async_load_build_config(self.project_dir) - self._load_config_to_ui() - def _load_config_to_ui(self) -> None: """将配置加载到UI""" # 从已有配置或编译配置中获取默认值 @@ -191,7 +165,7 @@ def _save_config_from_ui(self) -> None: self.config = existing_config def _validate_config(self) -> tuple[bool, str]: - """验证配置""" + """验证安装包配置(重写基类方法)""" if not self.config.get("installer_app_name"): return False, "请输入应用名称" if not self.config.get("installer_version"): @@ -202,24 +176,6 @@ def _validate_config(self) -> tuple[bool, str]: return False, "请输入源文件目录" return True, "" - def _validate_and_save(self) -> bool: - """验证配置""" - self._save_config_from_ui() - - is_valid, error_msg = self._validate_config() - if not is_valid: - self.app.notify(f"配置验证失败: {error_msg}", severity="error") - return False - - return True - - async def _async_save_config(self) -> bool: - """异步保存配置到文件""" - success = await async_save_build_config(self.project_dir, self.config) # type: ignore[arg-type] - if not success: - self.app.notify("配置保存失败", severity="error") - return success - def on_select_changed(self, event: Select.Changed) -> None: """处理平台选择变化""" if event.select.id == "platform-select": @@ -236,23 +192,11 @@ def on_button_pressed(self, event: Button.Pressed) -> None: if button_id == "back-btn": self.action_back() elif button_id == "save-btn": - asyncio.create_task(self.action_save()) + asyncio.create_task(self.action_save_async()) elif button_id == "next-btn": - asyncio.create_task(self.action_next()) - - def action_back(self) -> None: - """返回上一屏""" - self.app.pop_screen() - - async def action_save(self) -> None: - """保存配置""" - if self._validate_and_save(): - self._save_config_from_ui() - success = await self._async_save_config() - if success: - self.app.notify("配置已保存", severity="information") + asyncio.create_task(self._action_next()) - async def action_next(self) -> None: + async def _action_next(self) -> None: """进入下一步:平台专有选项""" if not self._validate_and_save(): return diff --git a/src/screens/package_options_screen.py b/src/screens/package_options_screen.py index 29751a8..1d5540a 100644 --- a/src/screens/package_options_screen.py +++ b/src/screens/package_options_screen.py @@ -4,9 +4,9 @@ """ import asyncio +import platform from pathlib import Path from textual.app import ComposeResult -from textual.screen import Screen from textual.containers import Container, Horizontal from textual.widgets import ( Static, @@ -15,35 +15,21 @@ Input, Select, ) -from textual.binding import Binding -from src.utils import ( - load_build_config, - async_load_build_config, - async_save_build_config, - validate_build_config, -) +from src.screens.base_config_screen import BaseConfigScreen +from src.utils import load_build_config from src.widgets import build_nuitka_options, build_pyinstaller_options -class PackageOptionsScreen(Screen): +class PackageOptionsScreen(BaseConfigScreen): """打包选项配置屏幕""" CSS_PATH = Path(__file__).parent.parent / "style" / "package_options_screen.tcss" - BINDINGS = [ - Binding("escape", "back", "返回"), - Binding("ctrl+s", "save", "保存"), - ] - - def __init__(self): + def __init__(self) -> None: super().__init__() - self.config = {} - self.project_dir: Path | None = None self.selected_plugins: list[str] = [] # 存储选中的插件 # 根据平台设置默认编译器 - import platform - os_type = platform.system() if os_type == "Windows": self.selected_compiler = "msvc" @@ -82,12 +68,9 @@ def compose(self) -> ComposeResult: "生成脚本", variant="success", id="generate-btn", flat=True ) - def on_mount(self) -> None: - """挂载时加载配置""" - self.project_dir = self.app.project_dir # type: ignore[assignment] - if not self.project_dir: - self.app.notify("未选择项目目录", severity="error") - self.app.pop_screen() + async def on_mount(self) -> None: + """挂载时加载配置(重写基类方法以支持异步字段创建)""" + if not self._init_project_dir(): return # 使用异步加载,避免阻塞渲染 @@ -95,37 +78,48 @@ def on_mount(self) -> None: async def _load_and_create_fields(self) -> None: """异步加载配置并创建字段""" + from src.utils import async_load_build_config + try: # 异步加载现有配置或使用默认配置 self.config = await async_load_build_config(self.project_dir) # type: ignore[arg-type] - # 加载插件配置 - plugins_value = self.config.get("plugins", "") - if isinstance(plugins_value, str): - self.selected_plugins = [ - p.strip() for p in plugins_value.split(",") if p.strip() - ] - else: - self.selected_plugins = ( - plugins_value if isinstance(plugins_value, list) else [] - ) + # 加载插件和编译器配置 + self._load_config_to_ui() - # 加载编译器配置 - self.selected_compiler = self.config.get("compiler", "msvc") - - # 根据构庻工具动态生成选项(开关值已在创庻时设置) + # 根据构建工具动态生成选项 self._create_options_fields() # 初始化界面状态 - if self.config.get("build_tool") == "nuitka": - self._update_no_pyi_switch_state() - elif self.config.get("build_tool") == "pyinstaller": - self._update_pyinstaller_input_states() + self._on_config_loaded() except Exception as e: # 错误处理:显示错误信息 self._show_load_error(str(e)) + def _load_config_to_ui(self) -> None: + """将配置加载到UI(实现基类方法)""" + # 加载插件配置 + plugins_value = self.config.get("plugins", "") + if isinstance(plugins_value, str): + self.selected_plugins = [ + p.strip() for p in plugins_value.split(",") if p.strip() + ] + else: + self.selected_plugins = ( + plugins_value if isinstance(plugins_value, list) else [] + ) + + # 加载编译器配置 + self.selected_compiler = self.config.get("compiler", self.selected_compiler) + + def _on_config_loaded(self) -> None: + """配置加载后的额外处理(重写基类方法)""" + if self.config.get("build_tool") == "nuitka": + self._update_no_pyi_switch_state() + elif self.config.get("build_tool") == "pyinstaller": + self._update_pyinstaller_input_states() + def _show_load_error(self, error_msg: str) -> None: """显示加载错误""" options_container = self.query_one("#options-fields", Container) @@ -501,15 +495,15 @@ def on_button_pressed(self, event: Button.Pressed) -> None: button_id = event.button.id if button_id == "back-btn": - asyncio.create_task(self.action_back()) + asyncio.create_task(self._action_back_with_save()) elif button_id == "save-btn": - asyncio.create_task(self.action_save()) + asyncio.create_task(self.action_save_async()) elif button_id == "generate-btn": - self.run_worker(self.action_generate()) + self.run_worker(self._action_generate()) elif button_id == "plugins-button": - self.run_worker(self.action_select_plugins()) + self.run_worker(self._action_select_plugins()) elif button_id == "compiler-button": - self.run_worker(self.action_select_compiler()) + self.run_worker(self._action_select_compiler()) elif button_id == "jobs-decrease-btn": self._adjust_jobs(-1) elif button_id == "jobs-increase-btn": @@ -527,8 +521,8 @@ def _adjust_jobs(self, delta: int) -> None: new_value = current_value + delta jobs_input.value = str(new_value) - async def action_back(self) -> None: - """返回上一屏""" + async def _action_back_with_save(self) -> None: + """返回上一屏(带自动保存)""" # 返回前自动保存配置 try: self._save_config_from_ui() @@ -538,7 +532,7 @@ async def action_back(self) -> None: self.app.notify(f"保存配置失败: {str(e)}", severity="error") self.app.pop_screen() - async def action_select_plugins(self) -> None: + async def _action_select_plugins(self) -> None: """打开插件选择界面""" from src.screens.plugin_selector_screen import PluginSelectorScreen @@ -553,7 +547,7 @@ async def action_select_plugins(self) -> None: plugin_count = len(result) self.app.notify(f"已选择 {plugin_count} 个插件", severity="information") - async def action_select_compiler(self) -> None: + async def _action_select_compiler(self) -> None: """打开编译器选择界面""" from src.screens.compiler_selector_screen import CompilerSelectorScreen @@ -575,38 +569,18 @@ async def action_select_compiler(self) -> None: compiler_name = compiler_names.get(result, result) self.app.notify(f"已选择编译器: {compiler_name}", severity="information") - def _validate_and_save(self) -> bool: - """验证配置,返回是否成功""" - self._save_config_from_ui() - - # 验证配置 - is_valid, error_msg = validate_build_config(self.config, self.project_dir) # type: ignore[arg-type] - if not is_valid: - self.app.notify(f"配置验证失败: {error_msg}", severity="error") - return False - - return True - - async def _async_save_config(self) -> bool: - """异步保存配置到文件""" - success = await async_save_build_config(self.project_dir, self.config) # type: ignore[arg-type] - if not success: - self.app.notify("配置保存失败", severity="error") - return success - - async def action_save(self) -> None: - """保存配置""" + async def action_save_async(self) -> None: + """异步保存配置(重写基类方法以添加冲突检查)""" # 保存前检查冲突 if self.config.get("build_tool") == "pyinstaller": self._check_collect_conflicts() if self._validate_and_save(): - self._save_config_from_ui() success = await self._async_save_config() if success: self.app.notify("配置已保存", severity="information") - async def action_generate(self) -> None: + async def _action_generate(self) -> None: """生成编译脚本""" if not self._validate_and_save(): return diff --git a/src/screens/project_selector_screen.py b/src/screens/project_selector_screen.py index ef147ad..3f02100 100644 --- a/src/screens/project_selector_screen.py +++ b/src/screens/project_selector_screen.py @@ -153,18 +153,18 @@ def _update_list_view(self, dir_items: List[Path]) -> None: def on_list_view_selected(self, event: ListView.Selected) -> None: """列表项选择事件""" # 检查是否是父目录项 - if hasattr(event.item, "is_parent") and event.item.is_parent: # type: ignore[attr-defined] + if hasattr(event.item, "is_parent") and event.item.is_parent: # 点击 ".." 返回上一级 parent_path = self.selected_path.parent if parent_path != self.selected_path: self.selected_path = parent_path self.update_selected_path() self.refresh_directory_list_async() - elif hasattr(event.item, "item_path"): # type: ignore[attr-defined] - item_path = event.item.item_path # type: ignore[attr-defined] - if item_path.is_dir(): # type: ignore[attr-defined] + elif hasattr(event.item, "item_path"): + item_path = event.item.item_path + if isinstance(item_path, Path) and item_path.is_dir(): # 点击文件夹,进入该目录 - self.selected_path = item_path # type: ignore[assignment] + self.selected_path = item_path self.update_selected_path() self.refresh_directory_list_async() # 文件不做处理 diff --git a/src/screens/welcome_screen.py b/src/screens/welcome_screen.py index 8ce7401..90b2f9a 100644 --- a/src/screens/welcome_screen.py +++ b/src/screens/welcome_screen.py @@ -2,31 +2,12 @@ 欢迎屏幕 """ -import pyfiglet from pathlib import Path from textual.app import ComposeResult from textual.screen import Screen from textual.containers import Container, Vertical, Horizontal -from textual.widgets import Static, Button, Link - - -def generate_logo(text="PyBuilder", font="big"): - """使用 pyfiglet 生成 ASCII Logo""" - try: - return pyfiglet.figlet_format(text, font=font) - except Exception: - # PyInstaller 打包后 pyfiglet.fonts 模块缺失时的降级方案 - return f"\n {text} \n" - - -# 动态生成 ASCII Logo - 延迟生成避免打包时导入错误 -# 可选字体(从小到大): -# "small" - 最小字体 -# "standard" - 标准字体 -# "big" - 大字体 -# "banner" - 横幅样式 -# "block" - 块状字体(较大) -# "3-d" - 3D效果(最大) +from textual.widgets import Button, Link, Static +from src.widgets.figlet_widget import FigletWidget class WelcomeScreen(Screen): @@ -36,11 +17,8 @@ class WelcomeScreen(Screen): def compose(self) -> ComposeResult: """创建欢迎界面组件""" - # 运行时生成 LOGO,避免模块导入阶段错误 - logo = generate_logo("PyBuilder", "big") - with Container(id="welcome-container"): - yield Static(logo, id="logo") + yield FigletWidget("PyBuilder", id="logo", font="big") with Container(id="title"): yield Link( "Github Star.", url="https://github.com/Y-ASLant/PyBuilder-Generate" diff --git a/src/widgets/__init__.py b/src/widgets/__init__.py index 2e9a68c..c99b722 100644 --- a/src/widgets/__init__.py +++ b/src/widgets/__init__.py @@ -2,6 +2,7 @@ 可复用的 UI 组件工厂模块 """ +from src.widgets.figlet_widget import FigletWidget, AnimatedFiglet from src.widgets.option_builders import ( create_switch_widget, create_input_widget, @@ -13,6 +14,8 @@ ) __all__ = [ + "FigletWidget", + "AnimatedFiglet", "create_switch_widget", "create_input_widget", "create_button_row", diff --git a/src/widgets/figlet_widget.py b/src/widgets/figlet_widget.py new file mode 100644 index 0000000..08d51d6 --- /dev/null +++ b/src/widgets/figlet_widget.py @@ -0,0 +1,221 @@ +""" +自定义 Figlet Widget - 兼容 Textual 7.x +""" + +from typing import Literal + +from pyfiglet import Figlet +from textual.widgets import Static + + +# 常用字体列表(从小到大) +FontSize = Literal[ + "small", + "standard", + "big", + "banner", + "block", + "3-d", + "3x5", + "5lineoblique", + "acrobatic", + "alligator", + "alphabet", + "avatar", + "basic", + "bubble", + "calgphy2", + "caligraphy", + "computer", + "digital", + "doom", + "epic", + "fourtops", + "gothic", + "graceful", + "grafitti", + "heart", + "helv", + "horizontal", + "italic", + "larry3d", + "lean", + "letter", + "lockergnome", + "madrid", + "marquee", + "mini", + "mike", + "nancyj", + "pebble", + "poison", + "puffy", + "roman", + "rounded", + "rowancap", + "script", + "shadow", + "slant", + "slide", + "speed", + "starwars", + "stampate", + "standard", + "stop", + "straight", + "tanja", + "thick", + "thin", + "tinker", + "tom", + "trek", + "tsalagi", + "twisted", + "usa", + "weird", +] + +JustifyType = Literal["left", "center", "right"] + + +class FigletWidget(Static): + """ + 使用 pyfiglet 生成 ASCII 艺术文本的 Widget + + 特性: + - 支持所有 pyfiglet 字体(带���型提示) + - 动态更新文本和字体 + - 支持对齐方式 + - 兼容 Textual 7.x + """ + + DEFAULT_CSS = """ + FigletWidget { + content-align: center middle; + min-height: 3; + text-style: bold; + } + """ + + def __init__( + self, + text: str, + *, + font: str = "standard", + name: str | None = None, + id: str | None = None, + classes: str | None = None, + disabled: bool = False, + ) -> None: + self._text = text + self._font = font + self._figlet = self._create_figlet(font) + # 生成初始内容 + ascii_art = self._render_ascii(text) + super().__init__( + ascii_art, + name=name, + id=id, + classes=classes, + disabled=disabled, + ) + + def _create_figlet(self, font: str) -> Figlet: + """创建 Figlet 实例,处理字体不存在的情况""" + try: + return Figlet(font=font) + except Exception: + # 字体不存在时回退到 standard + return Figlet(font="standard") + + def _render_ascii(self, text: str) -> str: + """渲染 ASCII 艺术文本""" + try: + return self._figlet.renderText(text) + except Exception: + # 降级方案:直接显示文本 + return f"\n{text}\n" + + @property + def text(self) -> str: + """获取当前文本""" + return self._text + + @property + def font(self) -> str: + """获取当前字体""" + return self._font + + def set_text(self, text: str, font: str | None = None) -> None: + """ + 更新 Widget 内容 + + Args: + text: 新文本 + font: 新字体(不改变则传 None) + """ + self._text = text + if font is not None: + self._font = font + self._figlet = self._create_figlet(font) + + # 重新渲染并更新显示 + ascii_art = self._render_ascii(self._text) + super().update(ascii_art) + + def set_font(self, font: str) -> None: + """设置字体(提供更直观的 API)""" + self._font = font + self._figlet = self._create_figlet(font) + ascii_art = self._render_ascii(self._text) + super().update(ascii_art) + + +class AnimatedFiglet(FigletWidget): + """ + 支持颜色的 Figlet Widget + + 使用 Textual 的样式标记实现颜色效果 + """ + + DEFAULT_CSS = """ + AnimatedFiglet { + content-align: center middle; + min-height: 3; + text-style: bold; + } + """ + + def __init__( + self, + text: str, + *, + font: str = "standard", + color: str = "primary", + name: str | None = None, + id: str | None = None, + classes: str | None = None, + ) -> None: + self._color = color + super().__init__(text, font=font, name=name, id=id, classes=classes) + # 重新渲染带颜色的内容 + self._update_color() + + def _update_color(self) -> None: + """更新带颜色的内容""" + ascii_art = self._render_ascii(self._text) + colored_art = f"[{self._color}]{ascii_art}[/{self._color}]" + super().update(colored_art) + + def set_text(self, text: str, font: str | None = None) -> None: + """更新内容并重新应用颜色""" + self._text = text + if font is not None: + self._font = font + self._figlet = self._create_figlet(font) + self._update_color() + + def set_color(self, color: str) -> None: + """设置颜色""" + self._color = color + self._update_color()