commit f7d5b7be072f0257227f911f7a9144f61ce88071 Author: jeffreytsai1004 Date: Sun Nov 23 20:41:50 2025 +0800 Update diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7896b34 --- /dev/null +++ b/.gitignore @@ -0,0 +1,45 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +*.manifest +*.spec + +# Config +config.json + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log + +build/ +dist/ diff --git a/CleanCache.bat b/CleanCache.bat new file mode 100644 index 0000000..c73e079 --- /dev/null +++ b/CleanCache.bat @@ -0,0 +1,37 @@ +@echo off +echo ======================================== +echo NexusLauncher Cache Cleaner +echo ======================================== +echo. + +@REM echo Close NexusLauncher if it is running... +@REM taskkill /f /im pythonw.exe + +echo [1/4] Cleaning all __pycache__ folders... +for /d /r %%d in (__pycache__) do @if exist "%%d" ( + echo Deleting: %%d + rd /s /q "%%d" +) +echo. + +echo [2/4] Cleaning root cache... +if exist "__pycache__" rd /s /q "__pycache__" +echo. + +echo [3/4] Cleaning module caches... +if exist "config\__pycache__" rd /s /q "config\__pycache__" +if exist "ui\__pycache__" rd /s /q "ui\__pycache__" +if exist "ui\task\__pycache__" rd /s /q "ui\task\__pycache__" +echo. + +echo [4/4] Cleaning .pyc files... +del /s /q *.pyc 2>nul +echo. + +@REM echo Clear old config file +@REM if exist config.json del /f config.json + +echo ======================================== +echo Cache cleaned successfully! +echo ======================================== +pause \ No newline at end of file diff --git a/Run.bat b/Run.bat new file mode 100644 index 0000000..b7fb480 --- /dev/null +++ b/Run.bat @@ -0,0 +1,28 @@ +@echo off +echo ======================================== +echo NexusLauncher Startup +echo ======================================== +echo. + +echo [1/3] Closing existing instances... +taskkill /f /im pythonw.exe 2>nul +echo. + +echo [2/3] Cleaning cache... +echo Cleaning all __pycache__ folders... +for /d /r %%d in (__pycache__) do @if exist "%%d" rd /s /q "%%d" + +echo Cleaning module caches... +if exist "__pycache__" rd /s /q "__pycache__" +if exist "config\__pycache__" rd /s /q "config\__pycache__" +if exist "ui\__pycache__" rd /s /q "ui\__pycache__" +if exist "ui\task\__pycache__" rd /s /q "ui\task\__pycache__" +echo. + +echo [3/3] Starting NexusLauncher... +start "" pythonw main.py + +echo ======================================== +echo NexusLauncher started! +echo ======================================== +exit diff --git a/RunDebug.bat b/RunDebug.bat new file mode 100644 index 0000000..611a843 --- /dev/null +++ b/RunDebug.bat @@ -0,0 +1,43 @@ +@echo off +echo ======================================== +echo NexusLauncher Debug Mode +echo ======================================== +echo. + +echo [1/4] Closing existing instances... +taskkill /f /im pythonw.exe 2>nul +taskkill /f /im python.exe 2>nul +echo. + +echo [2/4] Cleaning cache... +echo Cleaning all __pycache__ folders... +for /d /r %%d in (__pycache__) do @if exist "%%d" rd /s /q "%%d" + +echo Cleaning module caches... +if exist "__pycache__" rd /s /q "__pycache__" +if exist "config\__pycache__" rd /s /q "config\__pycache__" +if exist "ui\__pycache__" rd /s /q "ui\__pycache__" +if exist "ui\task\__pycache__" rd /s /q "ui\task\__pycache__" +echo. + +@REM echo Clear old config file +@REM if exist config.json del /f config.json + +echo [3/4] Checking Python version... +python --version +echo. + +echo [4/4] Launching NexusLauncher in Debug Mode... +echo Console window will remain open for debugging +echo Press Ctrl+C to stop the application +echo. +echo ======================================== +echo. + +python main.py + +echo. +echo ======================================== +echo NexusLauncher stopped +echo ======================================== +pause diff --git a/RunHidden.vbs b/RunHidden.vbs new file mode 100644 index 0000000..681f500 --- /dev/null +++ b/RunHidden.vbs @@ -0,0 +1,3 @@ +Set WshShell = CreateObject("WScript.Shell") +WshShell.Run "pythonw main.py", 0, False +Set WshShell = Nothing diff --git a/build.bat b/build.bat new file mode 100644 index 0000000..5acbb0f --- /dev/null +++ b/build.bat @@ -0,0 +1,61 @@ +@echo off +echo ======================================== +echo NexusLauncher Build Script +echo ======================================== +echo. + +echo [1/6] Check Python version... +python --version +if %errorlevel% neq 0 ( + echo Error: Python not found, please ensure Python 3.11 or higher is installed + pause + exit /b 1 +) +echo. + +echo [2/6] Install dependencies... +pip install -r requirements.txt +if %errorlevel% neq 0 ( + echo Error: Failed to install dependencies + pause + exit /b 1 +) +echo. + +echo [3/6] Close running instances... +taskkill /f /im NexusLauncher.exe 2>nul +if %errorlevel% equ 0 ( + echo Closed NexusLauncher.exe + timeout /t 2 /nobreak >nul +) else ( + echo No running instances found +) +echo. + +echo [4/6] Clean build directory... +if exist "dist" rd /s /q "dist" +if exist "build" rd /s /q "build" +if exist "NexusLauncher.spec" del /f "NexusLauncher.spec" +echo. + +echo [5/6] Build EXE using PyInstaller... +python -m PyInstaller --noconfirm --onefile --windowed --name "NexusLauncher" --icon="icons/NexusLauncher.ico" --add-data "icons;icons" main.py +if %errorlevel% neq 0 ( + echo Error: Failed to build EXE + pause + exit /b 1 +) +echo. + +echo [6/6] Copy EXE to template folder... +copy /y /b "dist\NexusLauncher.exe" "D:\NexusLauncher\NexusLauncher.exe" +echo. +echo ======================================== +echo Build completed! +echo Executable file location: D:\NexusLauncher\NexusLauncher.exe +echo ======================================== +echo. +echo Opening D:\NexusLauncher folder... +start "" "D:\NexusLauncher" +echo. +pause diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..88c9454 --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Config Module +------------- +配置管理相关模块 +""" + +from .config_manager import ConfigManager +from .icon_config import IconConfigManager +from . import constants + +__all__ = ['ConfigManager', 'IconConfigManager', 'constants'] diff --git a/config/config_manager.py b/config/config_manager.py new file mode 100644 index 0000000..8aa96fe --- /dev/null +++ b/config/config_manager.py @@ -0,0 +1,623 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +配置管理模块 - 负责读取和保存应用配置 +""" +import json +import os +import sys +from typing import Dict, List, Optional +from .icon_config import IconConfigManager +from .constants import DEFAULT_TASK_FOLDER_TEMPLATES + + +class ConfigManager: + """管理NexusLauncher的配置文件""" + + def __init__(self, config_file: str = "config.json"): + self.config_file = config_file + self.config_data = self._load_config() + + # 如果配置文件不存在,保存默认配置 + if not os.path.exists(self.config_file): + print("[INFO] Config file not found, creating default config.json") + self.save_config() + + # 创建图标配置管理器 + self.icon_config = IconConfigManager(self.config_data, self._get_icons_dir) + + def _get_icons_dir(self) -> str: + """获取 icons 目录路径(避免循环导入)""" + if getattr(sys, 'frozen', False): + # 打包后的应用 + return os.path.join(sys._MEIPASS, "icons") + else: + # 开发环境 + config_dir = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.dirname(config_dir) + return os.path.join(project_root, "icons") + + def _load_config(self) -> Dict: + """加载配置文件""" + if os.path.exists(self.config_file): + try: + with open(self.config_file, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + print(f"Failed to load configuration file: {e}") + return self._get_default_config() + else: + return self._get_default_config() + + def _get_default_config(self) -> Dict: + """获取默认配置""" + return { + "projects": { + "Project_01": { + "icon": "NexusLauncher.ico", # 默认项目图标 + "color": "", # 项目背景颜色 + "apps": [] + } + }, + "current_project": "Project_01", + "window_size": { + "width": 425, + "height": 480 + }, + "icon_size": 80, # 图标大小,默认80x80 + "app_icons": {}, # 应用图标映射,格式:{"app_path": "icon_path"} + "app_colors": {}, # 应用按钮颜色映射,格式:{"app_path": "#RRGGBB"} + "task_folder_templates": self._get_default_task_templates(), # 任务类型默认文件夹结构 + "task_settings": {} # 已废弃:旧的任务设置存储位置,现在存储在 projects.项目名.task_settings + } + + def save_config(self) -> bool: + """保存配置到文件""" + try: + # 重新排序项目字段:icon, color, apps, task_settings + if "projects" in self.config_data: + for project_name, project_data in self.config_data["projects"].items(): + ordered_project = {} + # 按顺序添加字段 + if "icon" in project_data: + ordered_project["icon"] = project_data["icon"] + if "color" in project_data: + ordered_project["color"] = project_data["color"] + if "apps" in project_data: + ordered_project["apps"] = project_data["apps"] + if "task_settings" in project_data: + ordered_project["task_settings"] = project_data["task_settings"] + + # 替换原有项目数据 + self.config_data["projects"][project_name] = ordered_project + + with open(self.config_file, 'w', encoding='utf-8') as f: + json.dump(self.config_data, f, ensure_ascii=False, indent=4) + return True + except Exception as e: + print(f"Failed to save configuration file: {e}") + return False + + def reload_config(self) -> bool: + """重新加载配置文件 + + Returns: + 是否加载成功 + """ + try: + self.config_data = self._load_config() + # 重新创建图标配置管理器以使用新的config_data引用 + self.icon_config = IconConfigManager(self.config_data, self._get_icons_dir) + return True + except Exception as e: + print(f"Failed to reload config file: {e}") + return False + + def get_projects(self) -> List[str]: + """获取所有项目名称""" + return list(self.config_data.get("projects", {}).keys()) + + def get_current_project(self) -> str: + """获取当前选中的项目""" + return self.config_data.get("current_project", "") + + def set_current_project(self, project_name: str): + """设置当前项目""" + if project_name in self.config_data.get("projects", {}): + self.config_data["current_project"] = project_name + self.save_config() + + def get_apps(self, project_name: Optional[str] = None) -> List[Dict]: + """获取指定项目的应用列表""" + if project_name is None: + project_name = self.get_current_project() + + projects = self.config_data.get("projects", {}) + if project_name in projects: + return projects[project_name].get("apps", []) + return [] + + def add_project(self, project_name: str, default_icon: str = None) -> bool: + """添加新项目 + + Args: + project_name: 项目名称 + default_icon: 默认图标路径(可选) + """ + if "projects" not in self.config_data: + self.config_data["projects"] = {} + + if project_name in self.config_data["projects"]: + return False + + self.config_data["projects"][project_name] = { + "icon": default_icon if default_icon else "", + "color": "", + "apps": [] + } + return self.save_config() + + def delete_project(self, project_name: str) -> bool: + """删除项目""" + if project_name in self.config_data.get("projects", {}): + # 删除项目配置(包含图标和颜色) + del self.config_data["projects"][project_name] + + # 如果删除的是当前项目,切换到第一个项目 + if self.config_data.get("current_project") == project_name: + projects = self.get_projects() + if projects: + self.config_data["current_project"] = projects[0] + else: + self.config_data["current_project"] = "" + + return self.save_config() + return False + + def rename_project(self, old_name: str, new_name: str) -> bool: + """重命名项目""" + # 检查旧项目是否存在 + if old_name not in self.config_data.get("projects", {}): + return False + + # 检查新名称是否已存在 + if new_name in self.config_data.get("projects", {}): + return False + + # 重命名项目 + self.config_data["projects"][new_name] = self.config_data["projects"].pop(old_name) + + # 如果重命名的是当前项目,更新当前项目名称 + if self.config_data.get("current_project") == old_name: + self.config_data["current_project"] = new_name + + return self.save_config() + + def add_app(self, project_name: str, app_name: str, app_path: str, version: str) -> bool: + """添加应用到指定项目""" + if project_name not in self.config_data.get("projects", {}): + return False + + app_data = { + "name": app_name, + "path": app_path, + "version": version + } + + self.config_data["projects"][project_name]["apps"].append(app_data) + return self.save_config() + + def update_app(self, project_name: str, app_index: int, app_name: str, app_path: str, version: str) -> bool: + """更新应用信息""" + if project_name not in self.config_data.get("projects", {}): + return False + + apps = self.config_data["projects"][project_name]["apps"] + if 0 <= app_index < len(apps): + apps[app_index] = { + "name": app_name, + "path": app_path, + "version": version + } + return self.save_config() + return False + + def delete_app(self, project_name: str, app_index: int) -> bool: + """删除应用""" + if project_name not in self.config_data.get("projects", {}): + return False + + apps = self.config_data["projects"][project_name]["apps"] + if 0 <= app_index < len(apps): + apps.pop(app_index) + return self.save_config() + return False + + def reorder_apps(self, project_name: str, from_index: int, to_index: int) -> bool: + """重新排序应用,支持拖到列表末尾""" + if project_name not in self.config_data.get("projects", {}): + return False + + apps = self.config_data["projects"][project_name]["apps"] + app_count = len(apps) + # 允许目标索引等于列表长度(表示插入到末尾) + if not (0 <= from_index < app_count and 0 <= to_index <= app_count): + return False + + app = apps.pop(from_index) + # 如果从上方拖到下方,移除后目标索引需要左移一位 + if from_index < to_index: + to_index -= 1 + # 再次夹紧,防止越界 + to_index = max(0, min(to_index, len(apps))) + apps.insert(to_index, app) + return self.save_config() + + def get_window_size(self) -> tuple: + """获取窗口大小""" + size = self.config_data.get("window_size", {"width": 400, "height": 400}) + return (size.get("width", 400), size.get("height", 400)) + + def save_window_size(self, width: int, height: int): + """保存窗口大小""" + self.config_data["window_size"] = {"width": width, "height": height} + self.save_config() + + def get_app_icon(self, app_path: str) -> str: + """获取应用图标路径""" + return self.icon_config.get_app_icon(app_path) + + def set_app_icon(self, app_path: str, icon_path: str) -> bool: + """设置应用图标路径""" + result = self.icon_config.set_app_icon(app_path, icon_path) + if result: + return self.save_config() + return False + + def remove_app_icon(self, app_path: str) -> bool: + """移除应用图标设置""" + result = self.icon_config.remove_app_icon(app_path) + if result: + return self.save_config() + return False + + def get_all_app_icons(self) -> Dict[str, str]: + """获取所有应用图标映射""" + return self.config_data.get("app_icons", {}) + + def get_app_color(self, app_path: str) -> str: + """获取应用按钮颜色""" + return self.icon_config.get_app_color(app_path) + + def set_app_color(self, app_path: str, color: str) -> bool: + """设置应用按钮颜色""" + result = self.icon_config.set_app_color(app_path, color) + if result: + return self.save_config() + return False + + def remove_app_color(self, app_path: str) -> bool: + """移除应用按钮颜色设置""" + result = self.icon_config.remove_app_color(app_path) + if result: + return self.save_config() + return False + + def get_all_app_colors(self) -> Dict[str, str]: + """获取所有应用按钮颜色映射""" + return self.config_data.get("app_colors", {}) + + def get_icon_size(self) -> int: + """获取图标大小""" + return self.config_data.get("icon_size", 80) + + def save_icon_size(self, size: int) -> bool: + """保存图标大小""" + self.config_data["icon_size"] = size + return self.save_config() + + def get_project_icon(self, project_name: str) -> str: + """获取项目图标路径""" + return self.icon_config.get_project_icon(project_name) + + def set_project_icon(self, project_name: str, icon_path: str) -> bool: + """设置项目图标路径""" + result = self.icon_config.set_project_icon(project_name, icon_path) + if result: + return self.save_config() + return False + + def remove_project_icon(self, project_name: str) -> bool: + """移除项目图标设置""" + result = self.icon_config.remove_project_icon(project_name) + if result: + return self.save_config() + return False + + def get_project_color(self, project_name: str) -> str: + """获取项目背景颜色""" + return self.icon_config.get_project_color(project_name) + + def set_project_color(self, project_name: str, color: str) -> bool: + """设置项目背景颜色""" + result = self.icon_config.set_project_color(project_name, color) + if result: + return self.save_config() + return False + + def remove_project_color(self, project_name: str) -> bool: + """移除项目背景颜色设置""" + result = self.icon_config.remove_project_color(project_name) + if result: + return self.save_config() + return False + + def _get_default_task_templates(self) -> Dict[str, List[str]]: + """获取默认任务类型文件夹模板""" + return DEFAULT_TASK_FOLDER_TEMPLATES + + def get_task_folder_template(self, task_type: str) -> List[str]: + """获取指定任务类型的文件夹模板 + + Args: + task_type: 任务类型名称 + + Returns: + 文件夹路径列表 + """ + templates = self.config_data.get("task_folder_templates", {}) + if task_type in templates: + return templates[task_type] + # 如果配置中没有,返回默认模板 + default_templates = self._get_default_task_templates() + return default_templates.get(task_type, []) + + def get_task_types(self) -> List[str]: + """获取所有可用的任务类型名称列表 + + Returns: + 任务类型名称列表 + """ + templates = self.config_data.get("task_folder_templates", {}) + if not templates: + # 如果配置中没有,使用默认模板 + templates = self._get_default_task_templates() + return sorted(list(templates.keys())) + + def get_all_task_folder_templates(self) -> Dict[str, List[str]]: + """获取所有任务类型的文件夹模板 + + Returns: + 任务类型到文件夹列表的映射 + """ + templates = self.config_data.get("task_folder_templates", {}) + if not templates: + # 如果配置中没有,初始化默认模板并保存 + templates = self._get_default_task_templates() + self.config_data["task_folder_templates"] = templates + self.save_config() + return templates + + def set_task_folder_template(self, task_type: str, folders: List[str]) -> bool: + """设置指定任务类型的文件夹模板 + + Args: + task_type: 任务类型名称 + folders: 文件夹路径列表 + + Returns: + 是否保存成功 + """ + if "task_folder_templates" not in self.config_data: + self.config_data["task_folder_templates"] = {} + + # 统一路径格式为正斜杠(JSON 存储格式) + normalized_folders = [folder.replace("\\", "/") for folder in folders] + self.config_data["task_folder_templates"][task_type] = normalized_folders + return self.save_config() + + def update_all_task_folder_templates(self, templates: Dict[str, List[str]]) -> bool: + """更新所有任务类型的文件夹模板 + + Args: + templates: 任务类型到文件夹列表的映射 + + Returns: + 是否保存成功 + """ + # 统一所有模板的路径格式为正斜杠(JSON 存储格式) + normalized_templates = {} + for task_type, folders in templates.items(): + normalized_templates[task_type] = [folder.replace("\\", "/") for folder in folders] + + self.config_data["task_folder_templates"] = normalized_templates + return self.save_config() + + def get_task_settings(self, project_name: str) -> Dict: + """获取指定项目的 Task 面板设置 + + Args: + project_name: 项目名称 + + Returns: + Task 设置字典,包含 workspace, task_type, use_type_hierarchy + """ + # 确保 projects 字段存在 + if "projects" not in self.config_data: + self.config_data["projects"] = {} + + # 确保项目存在 + if project_name not in self.config_data["projects"]: + self.config_data["projects"][project_name] = {} + + # 从 projects.项目名.task_settings 读取 + project_data = self.config_data["projects"][project_name] + settings = project_data.get("task_settings", { + "workspace": "D:\\Workspace", + "task_type": "Character", + "use_type_hierarchy": False + }) + + # 标准化路径格式(JSON中存储正斜杠,转换为反斜杠供UI显示) + if "workspace" in settings and settings["workspace"]: + settings["workspace"] = settings["workspace"].replace("/", "\\") + if "maya_plugin_path" in settings and settings["maya_plugin_path"]: + settings["maya_plugin_path"] = settings["maya_plugin_path"].replace("/", "\\") + if "sp_shelf_path" in settings and settings["sp_shelf_path"]: + settings["sp_shelf_path"] = settings["sp_shelf_path"].replace("/", "\\") + + return settings + + def set_task_settings(self, project_name: str, workspace: str = None, + task_type: str = None, use_type_hierarchy: bool = None, + maya_plugin_path: str = None, sp_shelf_path: str = None) -> bool: + """设置指定项目的 Task 面板设置 + + Args: + project_name: 项目名称 + workspace: 工作空间路径 + task_type: 任务类型 + use_type_hierarchy: 是否使用类型层级 + maya_plugin_path: Maya 插件路径 + sp_shelf_path: Substance Painter 架子路径 + + Returns: + 是否保存成功 + """ + # 确保 projects 字段存在 + if "projects" not in self.config_data: + self.config_data["projects"] = {} + + # 确保项目存在 + if project_name not in self.config_data["projects"]: + self.config_data["projects"][project_name] = {} + + # 确保 task_settings 字段存在 + if "task_settings" not in self.config_data["projects"][project_name]: + self.config_data["projects"][project_name]["task_settings"] = {} + + # 保存到 projects.项目名.task_settings + settings = self.config_data["projects"][project_name]["task_settings"] + + # 保存 SubFolders(如果存在,先临时保存) + subfolders = settings.get("SubFolders") + + # 按顺序更新字段 + if workspace is not None: + settings["workspace"] = workspace.replace("\\", "/") + if task_type is not None: + settings["task_type"] = task_type + if use_type_hierarchy is not None: + settings["use_type_hierarchy"] = use_type_hierarchy + if maya_plugin_path is not None: + settings["maya_plugin_path"] = maya_plugin_path.replace("\\", "/") + if sp_shelf_path is not None: + settings["sp_shelf_path"] = sp_shelf_path.replace("\\", "/") + + # 重新构建有序字典,确保字段顺序:workspace, maya_plugin_path, sp_shelf_path, task_type, use_type_hierarchy, SubFolders + ordered_settings = {} + for key in ["workspace", "maya_plugin_path", "sp_shelf_path", "task_type", "use_type_hierarchy"]: + if key in settings: + ordered_settings[key] = settings[key] + + # 将 SubFolders 放在最后 + if subfolders is not None: + ordered_settings["SubFolders"] = subfolders + + # 替换原有的 task_settings + self.config_data["projects"][project_name]["task_settings"] = ordered_settings + + print(f"[OK] Save the task settings to projects.{project_name}.task_settings") + return self.save_config() + + def copy_apps_to_config(self, apps_data: List[Dict]) -> bool: + """将复制的应用数据保存到配置文件""" + try: + if "clipboard" not in self.config_data: + self.config_data["clipboard"] = {} + + self.config_data["clipboard"]["apps"] = apps_data + print(f"[DEBUG] Saved {len(apps_data)} apps to config clipboard") + return self.save_config() + except Exception as e: + print(f"[ERROR] Failed to save apps to config clipboard: {e}") + return False + + def get_clipboard_apps(self) -> List[Dict]: + """从配置文件获取复制的应用数据""" + try: + if "clipboard" in self.config_data and "apps" in self.config_data["clipboard"]: + apps = self.config_data["clipboard"]["apps"] + print(f"[DEBUG] Retrieved {len(apps)} apps from config clipboard") + return apps + else: + print("[DEBUG] No apps in config clipboard") + return [] + except Exception as e: + print(f"[ERROR] Failed to get apps from config clipboard: {e}") + return [] + + def clear_clipboard_apps(self) -> bool: + """清空配置文件中的应用剪贴板""" + try: + if "clipboard" in self.config_data: + self.config_data["clipboard"]["apps"] = [] + print("[DEBUG] Cleared config clipboard") + return self.save_config() + return True + except Exception as e: + print(f"[ERROR] Failed to clear config clipboard: {e}") + return False + + def save_selection_state(self, project_name: str, selected_indices: List[int]) -> bool: + """保存项目的选择状态到配置文件""" + try: + if "selection_states" not in self.config_data: + self.config_data["selection_states"] = {} + + self.config_data["selection_states"][project_name] = selected_indices + print(f"[DEBUG] Saved selection state for {project_name}: {selected_indices}") + return self.save_config() + except Exception as e: + print(f"[ERROR] Failed to save selection state: {e}") + return False + + def get_selection_state(self, project_name: str) -> List[int]: + """从配置文件获取项目的选择状态""" + try: + if ("selection_states" in self.config_data and + project_name in self.config_data["selection_states"]): + indices = self.config_data["selection_states"][project_name] + print(f"[DEBUG] Retrieved selection state for {project_name}: {indices}") + return indices + else: + print(f"[DEBUG] No selection state found for {project_name}") + return [] + except Exception as e: + print(f"[ERROR] Failed to get selection state: {e}") + return [] + + def select_all_apps(self, project_name: str) -> List[int]: + """选择项目的所有应用并保存状态""" + try: + apps = self.get_apps(project_name) + all_indices = list(range(len(apps))) + self.save_selection_state(project_name, all_indices) + print(f"[DEBUG] Selected all {len(all_indices)} apps in {project_name}") + return all_indices + except Exception as e: + print(f"[ERROR] Failed to select all apps: {e}") + return [] + + def clear_selection_state(self, project_name: str) -> bool: + """清空项目的选择状态""" + try: + if ("selection_states" in self.config_data and + project_name in self.config_data["selection_states"]): + self.config_data["selection_states"][project_name] = [] + print(f"[DEBUG] Cleared selection state for {project_name}") + return self.save_config() + return True + except Exception as e: + print(f"[ERROR] Failed to clear selection state: {e}") + return False diff --git a/config/constants.py b/config/constants.py new file mode 100644 index 0000000..5ee1f9f --- /dev/null +++ b/config/constants.py @@ -0,0 +1,383 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Application Constants +-------------------- +应用程序的常量定义,按功能模块分类 +""" +# ==================== 默认路径 ==================== + +# 默认项目文件夹路径 +DEFAULT_WORKSPACE_PATH = "D:\\Workspace" + +# 默认Maya插件文件夹路径 +DEFAULT_MAYA_PLUGINS_PATH = "D:\\Plugins\\Maya" + +# 默认SPShelf文件夹路径 +DEFAULT_SP_SHELF_PATH = "D:\\Plugins\\SPShelf" + +# ==================== 项目模板配置 ==================== + +# 默认任务类型文件夹模板 + +DEFAULT_TASK_FOLDER_TEMPLATES = { + "Character": [ + "Reference", + "MP", + "HP", + "LP", + "Baking", + "Baking/HP", + "Baking/LP", + "Texture", + "Texture/MeshMaps", + "Texture/SP", + "FBX", + "Screenshot" + ], + "Weapon": [ + "Reference", + "MP", + "HP", + "LP", + "Baking", + "Baking/HP", + "Baking/LP", + "Texture", + "Texture/MeshMaps", + "Texture/SP", + "FBX", + "Screenshot" + ], + "Prop": [ + "Reference", + "MP", + "HP", + "LP", + "Baking", + "Baking/HP", + "Baking/LP", + "Texture", + "Texture/MeshMaps", + "Texture/SP", + "FBX", + "Screenshot" + ], + "Environment": [ + "Reference", + "MP", + "HP", + "LP", + "Baking", + "Baking/HP", + "Baking/LP", + "Texture", + "Texture/MeshMaps", + "Texture/SP", + "FBX", + "Screenshot" + ], + "Animation": [ + "Reference", + "Maya", + "FBX", + "Mocap" + ], + "Rigging": [ + "Source", + "Maya", + "FBX" + ], + "Other": [ + "Reference", + "MP", + "HP", + "LP", + "Baking", + "Baking/HP", + "Baking/LP", + "Texture", + "Texture/MeshMaps", + "Texture/SP", + "FBX", + "Screenshot" + ] +} + +# ==================== 图标映射配置 ==================== + +# 应用名称到图标的映射 +APP_ICON_MAPPING = { + "maya": "Maya", + "maya2025": "Maya", + "maya2026": "Maya", + "maya2027": "Maya", + "maya2028": "Maya", + "maya2029": "Maya", + "maya2020": "Maya", + "maya2021": "Maya", + "maya2022": "Maya", + "maya2023": "Maya", + "maya2024": "Maya", + "ma": "Maya", + "3dsmax": "3DsMax", + "3ds": "3DsMax", + "3ds-max": "3DsMax", + "max": "3DsMax", + "blender": "Blender", + "photoshop": "Photoshop", + "painter": "SubstancePainter", + "3dpainter": "SubstancePainter", + "substancepainter": "SubstancePainter", + "substance3dpainter": "SubstancePainter", + "sp": "SubstancePainter", + "designer": "SubstanceDesigner", + "3ddesigner": "SubstanceDesigner", + "substancedesigner": "SubstanceDesigner", + "substance3ddesigner": "SubstanceDesigner", + "sd": "SubstanceDesigner", + "marvelousdesigner": "MarvelousDesigner", + "marvelous": "MarvelousDesigner", + "md": "MarvelousDesigner", + "marvelousdesigner": "MarvelousDesigner", + "marvelous": "MarvelousDesigner", + "rizom": "RizomUV", + "rizomuv": "RizomUV", + "zbrush": "Zbrush", + "ue": "UnrealEngine", + "ue4": "UnrealEngine", + "ue5": "UnrealEngine", + "ue6": "UnrealEngine", + "unrealengine": "UnrealEngine", + "unrealtoolbox": "UnrealEngine", + "unrealgamesync": "UnrealGameSync", + "ugs": "UnrealGameSync", + "uefn": "UEFN", + "marmoset": "MarmosetToolBag", + "marmosettoolbag": "MarmosetToolBag", + "toolbag": "MarmosetToolBag", + "3dcoat": "3DCoat", + "houdini": "Houdini", + "houdinifx": "Houdini", + "houdiniengine": "Houdini", + "everything": "Everything", + "billfish": "Billfish", + "eagle": "Eagle" +} + +# ==================== 通用UI常量 ==================== + +# 预设颜色列表 +PRESET_COLORS = [ + "#607d8b", # 蓝灰色(默认) + "#2196f3", # 蓝色 + "#f44336", # 红色 + "#4caf50", # 绿色 + "#ff9800", # 橙色 + "#9c27b0", # 紫色 + "#00bcd4", # 青色 + "#ffeb3b", # 黄色 + "#009688", # 青绿色 + "#673ab7", # 深紫色 + "#3f51b5", # 青蓝色 + "#795548" # 棕色 +] + +# 基础颜色 +BG_COLOR_DARK = "#2b2b2b" +BG_COLOR_LIGHT = "#3a3a3a" +BG_COLOR_FRAME = "#3a3a3a" +BG_COLOR_BUTTON = "#4a5568" +BG_COLOR_BUTTON_HOVER = "#2d3748" +COLOR_TRANSPARENT = "transparent" +BORDER_COLOR = "#555555" +BORDER_COLOR_WHITE = "#ffffff" +LINE_COLOR_GRAY = "#aaaaaa" + +# 文本颜色 +TEXT_COLOR_PRIMARY = "white" +TEXT_COLOR_SECONDARY = "gray" +TEXT_COLOR_WHITE = "#ffffff" + +# 状态颜色 +COLOR_SUCCESS = "#28a745" +COLOR_SUCCESS_HOVER = "#218838" +COLOR_ERROR = "#dc3545" +COLOR_ERROR_HOVER = "#c82333" +COLOR_WARNING = "#ffc107" +COLOR_INFO = "#17a2b8" + +# 通用按钮颜色 +BUTTON_GRAY = "#757575" +BUTTON_GRAY_HOVER = "#616161" +BUTTON_RED = "#d32f2f" +BUTTON_RED_HOVER = "#b71c1c" +BUTTON_BLUE = "#2d6ba0" +BUTTON_BLUE_HOVER = "#1d5b90" +BUTTON_GREEN = "#3a8545" +BUTTON_GREEN_HOVER = "#2a7535" + +# 对话框颜色 +DIALOG_BG_COLOR = "#2b2b2b" +DIALOG_TEXT_COLOR = "#e0e0e0" + +# 拖拽和选择颜色 +DRAG_HIGHLIGHT_COLOR = "#3584e4" +DRAG_HIGHLIGHT_BG = "#2a3f52" +SELECTION_BORDER = "#1e5a96" # 更深的蓝色边框 +SELECTION_BG = "#2d3441" # 选择时的背景色(更亮一些,保持可读性) + +# ==================== 主窗口常量 ==================== + +# 滚动条颜色 - 与卡片颜色统一 +SCROLLBAR_COLOR = "#2b2b2b" # 与卡片背景色一致 +SCROLLBAR_HOVER_COLOR = "#3a3a3a" # 悬停时稍亮一些 + +# 分段按钮颜色 +SEGMENTED_BUTTON_SELECTED_COLOR = "#4a5568" +SEGMENTED_BUTTON_SELECTED_HOVER_COLOR = "#2d3748" +SEGMENTED_BUTTON_UNSELECTED_COLOR = "#3a3a3a" +SEGMENTED_BUTTON_UNSELECTED_HOVER_COLOR = "#4a4a4a" + +# 下拉菜单颜色 +DROPDOWN_FG_COLOR = "#2b2b2b" +DROPDOWN_HOVER_COLOR = "#4a5568" + +# ==================== 项目管理面板常量 ==================== + +# 项目面板背景颜色(与任务面板保持一致) +PROJECT_PANEL_BG_LIGHT = "#3B4252" +PROJECT_PANEL_BG_DARK = "#2E3440" + +# ==================== 设置窗口常量 ==================== + +# 特殊按钮颜色 +SAVE_BUTTON_COLOR = "#2e7d32" +SAVE_BUTTON_HOVER = "#1b5e20" +SAVE_BUTTON_BORDER = "#34d058" + +# ==================== 任务管理面板常量 ==================== + +# 任务面板颜色 +TASK_PANEL_BG_LIGHT = "#3B4252" +TASK_PANEL_BG_DARK = "#2E3440" + +# 重置按钮颜色 +RESET_BUTTON_BORDER = "#868e96" + +# ==================== 节点编辑器常量 ==================== + +# 画布和网格颜色 +NODE_CANVAS_BG = "#1a202c" +NODE_GRID_COLOR = "#2d3748" + +# 节点颜色 +NODE_BG_COLOR = "#2d2d2d" +NODE_BORDER_COLOR = "#3a3a3a" +NODE_SELECTED_BORDER = "#00d9ff" +NODE_ID_TEXT_COLOR = "#888888" + +# 连接点和连接线颜色 +NODE_INPUT_COLOR = "#5a9fd4" +NODE_OUTPUT_COLOR = "#10b981" +NODE_CONNECTION_COLOR = "#5a9fd4" +NODE_CONNECTION_SELECTED = "#ff6b6b" + +# 节点颜色调色板 +NODE_COLOR_PALETTE = [ + "#5a9fd4", # 蓝色 + "#10b981", # 绿色 + "#d97706", # 橙色 + "#dc2626", # 红色 + "#8b5cf6", # 紫色 + "#ec4899", # 粉色 + "#06b6d4", # 青色 + "#f59e0b", # 黄色 + "#6366f1", # 靛蓝 + "#14b8a6", # 青绿 + "#f97316", # 深橙 + "#a855f7", # 紫罗兰 +] + +# ==================== 通用尺寸常量 ==================== + +# 窗口尺寸常量 +# 主窗口尺寸 +CONSOLE_WINDOW_SIZE = "600x400" + +# 设置窗口尺寸 +SETTINGS_WINDOW_SIZE = "650x800" + +# SubFolder Editor 窗口尺寸 +SUBFOLDER_EDITOR_WINDOW_SIZE = "1200x900" +SUBFOLDER_EDITOR_MIN_SIZE = (1000, 800) + +# 对话框尺寸 +DIALOG_INPUT_SIZE = "400x220" +DIALOG_CONFIRM_SIZE = "450x200" +DIALOG_APP_EDIT_SIZE = "650x700" +DIALOG_ICON_SELECT_SIZE = "600x400" +DIALOG_MESSAGE_SIZE = "400x250" +DIALOG_YES_NO_SIZE = "450x250" +DIALOG_NODE_RENAME_SIZE = "400x180" + +# 对话框尺寸 (width, height) - 保持向后兼容 +DIALOG_SIZE_SMALL = (400, 220) +DIALOG_SIZE_MEDIUM = (450, 200) +DIALOG_SIZE_LARGE = (650, 700) +DIALOG_SIZE_XLARGE = (600, 400) + +# 图标尺寸 +ICON_SIZE_TINY = 1 +ICON_SIZE_SMALL = 22 +ICON_SIZE_MEDIUM = 48 +ICON_SIZE_LARGE = 64 +ICON_SIZE_XLARGE = 128 + +# 按钮尺寸 +BUTTON_WIDTH_SMALL = 80 +BUTTON_WIDTH_MEDIUM = 100 +BUTTON_WIDTH_LARGE = 120 +BUTTON_HEIGHT_SMALL = 30 +BUTTON_HEIGHT_MEDIUM = 40 + +# 圆角半径 +CORNER_RADIUS_SMALL = 8 +CORNER_RADIUS_MEDIUM = 10 +CORNER_RADIUS_LARGE = 15 + +# 间距 +PADDING_SMALL = 5 +PADDING_MEDIUM = 10 +PADDING_LARGE = 20 + +# ==================== 字体常量 ==================== + +FONT_SIZE_TINY = 8 +FONT_SIZE_SMALL = 10 +FONT_SIZE_MEDIUM = 12 +FONT_SIZE_LARGE = 13 +FONT_SIZE_XLARGE = 16 + +# ==================== 应用配置常量 ==================== + +# 图标延迟设置时间 +ICON_DELAY_SHORT = 10 +ICON_DELAY_MEDIUM = 50 +ICON_DELAY_LONG = 200 + +# 默认窗口设置 +DEFAULT_ICON_SIZE = 80 +MIN_ICON_SIZE = 50 +MAX_ICON_SIZE = 150 +DEFAULT_WINDOW_WIDTH = 425 +DEFAULT_WINDOW_HEIGHT = 480 +MIN_WINDOW_WIDTH = 200 +MIN_WINDOW_HEIGHT = 200 + +# 任务栏高度(用于窗口定位) +TASKBAR_HEIGHT = 80 + +# 网格列数 +MAX_GRID_COLUMNS = 7 +DEFAULT_GRID_COLUMNS = 3 diff --git a/config/icon_config.py b/config/icon_config.py new file mode 100644 index 0000000..a32b131 --- /dev/null +++ b/config/icon_config.py @@ -0,0 +1,312 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +图标和颜色配置管理器 +负责管理应用和项目的图标、颜色配置 +""" +import os +from typing import Dict, Optional + + +class IconConfigManager: + """图标和颜色配置管理器""" + + def __init__(self, config_data: Dict, get_icons_dir_func): + """ + 初始化图标配置管理器 + + Args: + config_data: 配置数据字典的引用 + get_icons_dir_func: 获取图标目录的函数 + """ + self.config_data = config_data + self._get_icons_dir = get_icons_dir_func + + # ==================== 应用图标管理 ==================== + + def get_app_icon(self, app_path: str) -> str: + """ + 获取应用图标路径 + + 如果配置中保存的是相对路径(只有文件名),则自动拼接 icons 目录 + + Args: + app_path: 应用路径 + + Returns: + 图标的完整路径,如果不存在则返回空字符串 + """ + icon_value = self.config_data.get("app_icons", {}).get(app_path, "") + + if not icon_value: + return "" + + # 如果是绝对路径且存在,直接返回 + if os.path.isabs(icon_value) and os.path.exists(icon_value): + return icon_value + + # 如果是相对路径(只有文件名),拼接 icons 目录 + if not os.path.isabs(icon_value): + icons_dir = self._get_icons_dir() + full_path = os.path.join(icons_dir, icon_value) + if os.path.exists(full_path): + return full_path + + # 如果都不存在,返回原值(可能是旧的绝对路径) + return icon_value + + def set_app_icon(self, app_path: str, icon_path: str) -> bool: + """ + 设置应用图标路径 + + 如果图标在 icons 目录下,只保存文件名(相对路径) + 否则保存完整路径 + + Args: + app_path: 应用路径 + icon_path: 图标路径 + + Returns: + 是否设置成功 + """ + if "app_icons" not in self.config_data: + self.config_data["app_icons"] = {} + + # 获取 icons 目录 + icons_dir = self._get_icons_dir() + + # 如果图标在 icons 目录下,只保存文件名 + try: + # 标准化路径以便比较 + icon_path_normalized = os.path.normpath(icon_path) + icons_dir_normalized = os.path.normpath(icons_dir) + + # 检查是否在 icons 目录下 + if icon_path_normalized.startswith(icons_dir_normalized): + # 只保存文件名 + icon_filename = os.path.basename(icon_path) + self.config_data["app_icons"][app_path] = icon_filename + print(f"[OK] Saved icon as relative path: {icon_filename}") + else: + # 保存完整路径 + self.config_data["app_icons"][app_path] = icon_path + print(f"[OK] Saved icon as absolute path: {icon_path}") + + return True + except Exception as e: + print(f"[WARNING] Error processing icon path: {e}") + # 出错时保存完整路径 + self.config_data["app_icons"][app_path] = icon_path + return False + + def remove_app_icon(self, app_path: str) -> bool: + """ + 移除应用图标设置 + + Args: + app_path: 应用路径 + + Returns: + 是否移除成功 + """ + if app_path in self.config_data.get("app_icons", {}): + del self.config_data["app_icons"][app_path] + return True + return False + + # ==================== 项目图标管理 ==================== + + def get_project_icon(self, project_name: str) -> str: + """ + 获取项目图标路径 + + 如果配置中保存的是相对路径(只有文件名),则自动拼接 icons 目录 + + Args: + project_name: 项目名称 + + Returns: + 图标的完整路径,如果不存在则返回空字符串 + """ + # 从项目配置中获取图标路径 + projects = self.config_data.get("projects", {}) + project_config = projects.get(project_name, {}) + icon_value = project_config.get("icon", "") + + if not icon_value: + return "" + + # 如果是绝对路径且存在,直接返回 + if os.path.isabs(icon_value) and os.path.exists(icon_value): + return icon_value + + # 如果是相对路径(只有文件名),拼接 icons 目录 + if not os.path.isabs(icon_value): + icons_dir = self._get_icons_dir() + full_path = os.path.join(icons_dir, icon_value) + if os.path.exists(full_path): + return full_path + + # 如果都不存在,返回原值(可能是旧的绝对路径) + return icon_value + + def set_project_icon(self, project_name: str, icon_path: str) -> bool: + """ + 设置项目图标路径 + + 如果图标在 icons 目录下,只保存文件名(相对路径) + 否则保存完整路径 + + Args: + project_name: 项目名称 + icon_path: 图标路径 + + Returns: + 是否设置成功 + """ + # 确保projects配置存在 + if "projects" not in self.config_data: + self.config_data["projects"] = {} + + # 确保项目配置存在 + if project_name not in self.config_data["projects"]: + self.config_data["projects"][project_name] = { + "apps": [], + "icon": "", + "color": "" + } + + # 获取 icons 目录 + icons_dir = self._get_icons_dir() + + # 如果图标在 icons 目录下,只保存文件名 + try: + # 标准化路径以便比较 + icon_path_normalized = os.path.normpath(icon_path) + icons_dir_normalized = os.path.normpath(icons_dir) + + # 检查是否在 icons 目录下 + if icon_path_normalized.startswith(icons_dir_normalized): + # 只保存文件名 + icon_filename = os.path.basename(icon_path) + self.config_data["projects"][project_name]["icon"] = icon_filename + print(f"[OK] Saved project icon as relative path: {icon_filename}") + else: + # 保存完整路径 + self.config_data["projects"][project_name]["icon"] = icon_path + print(f"[OK] Saved project icon as absolute path: {icon_path}") + + return True + except Exception as e: + print(f"[WARNING] Error processing project icon path: {e}") + # 出错时保存完整路径 + self.config_data["projects"][project_name]["icon"] = icon_path + return False + + def remove_project_icon(self, project_name: str) -> bool: + """ + 移除项目图标设置 + + Args: + project_name: 项目名称 + + Returns: + 是否移除成功 + """ + projects = self.config_data.get("projects", {}) + if project_name in projects and "icon" in projects[project_name]: + projects[project_name]["icon"] = "" + return True + return False + + # ==================== 应用颜色管理 ==================== + + def get_app_color(self, app_path: str) -> str: + """ + 获取应用按钮颜色 + + Args: + app_path: 应用路径 + + Returns: + 颜色值(十六进制),如果未设置则返回空字符串 + """ + return self.config_data.get("app_colors", {}).get(app_path, "") + + def set_app_color(self, app_path: str, color: str) -> bool: + """ + 设置应用按钮颜色 + + Args: + app_path: 应用路径 + color: 颜色值(十六进制) + + Returns: + 是否设置成功 + """ + if "app_colors" not in self.config_data: + self.config_data["app_colors"] = {} + + self.config_data["app_colors"][app_path] = color + return True + + def remove_app_color(self, app_path: str) -> bool: + """ + 移除应用按钮颜色设置 + + Args: + app_path: 应用路径 + + Returns: + 是否移除成功 + """ + if app_path in self.config_data.get("app_colors", {}): + del self.config_data["app_colors"][app_path] + return True + return False + + # ==================== 项目颜色管理 ==================== + + def get_project_color(self, project_name: str) -> str: + """ + 获取项目背景颜色 + + Args: + project_name: 项目名称 + + Returns: + 颜色值(十六进制),默认为 #2b4c6f + """ + return self.config_data.get("project_colors", {}).get(project_name, "#2b4c6f") + + def set_project_color(self, project_name: str, color: str) -> bool: + """ + 设置项目背景颜色 + + Args: + project_name: 项目名称 + color: 颜色值(十六进制) + + Returns: + 是否设置成功 + """ + if "project_colors" not in self.config_data: + self.config_data["project_colors"] = {} + + self.config_data["project_colors"][project_name] = color + return True + + def remove_project_color(self, project_name: str) -> bool: + """ + 移除项目背景颜色设置 + + Args: + project_name: 项目名称 + + Returns: + 是否移除成功 + """ + if project_name in self.config_data.get("project_colors", {}): + del self.config_data["project_colors"][project_name] + return True + return False diff --git a/docs/ARTIST_GUIDE.md b/docs/ARTIST_GUIDE.md new file mode 100644 index 0000000..72b76b5 --- /dev/null +++ b/docs/ARTIST_GUIDE.md @@ -0,0 +1,298 @@ +# NexusLauncher 使用指南 + +> **现代化项目管理与应用启动工具** +> 版本: v1.2.0 | 更新: 2025年11月 + +--- + +## 📑 目录 + +- [快速开始](#快速开始) +- [核心功能](#核心功能) +- [项目管理](#项目管理) +- [任务管理](#任务管理) +- [应用管理](#应用管理) +- [快捷键](#快捷键) +- [常见问题](#常见问题) + +--- + +## 快速开始 + +### 安装 + +1. **下载并解压** `NexusLauncher.exe` 到任意目录 +2. **双击运行**,程序将自动创建配置文件 +3. **首次启动**会创建示例项目 "Project_01" + +### 5 分钟上手 + +#### 1️⃣ 添加常用软件 + +``` +主窗口 → ⚙ 设置 → + 添加应用 +``` + +填写信息: +- **名称**: Maya 2025 +- **路径**: `C:\Program Files\Autodesk\Maya2025\bin\maya.exe` +- **版本**: 2025.1 + +#### 2️⃣ 创建项目 + +``` +设置窗口 → 新建项目 → 输入项目名称 +``` + +项目命名建议: +- `Character_Hero` +- `Environment_Forest` +- `Weapon_Sword` + +#### 3️⃣ 创建任务文件夹 + +``` +Task 标签页 → 选择任务类型 → 设置工作空间 → Create Task Folder +``` + +#### 4️⃣ 启动应用 + +``` +Project 标签页 → 点击应用图标 +``` + +💡 **提示**: 使用 `Ctrl + 滚轮` 调整图标大小 + +--- + +## 核心功能 + +### 🎨 项目管理 +- 多项目支持,快速切换 +- 项目个性化(图标、颜色) +- 自动保存配置 + +### 📁 任务管理 +- 7 种预设任务模板(Character、Weapon、Prop 等) +- 可视化节点编辑器 +- 一键创建标准化文件夹结构 + +### 🚀 应用启动 +- 快速启动常用软件 +- 图标缩放(`Ctrl + 滚轮`) +- 拖拽排序 + +### 界面布局 + +``` +┌──────────────────────────────┐ +│ NexusLauncher ⚙ 设置 × │ +├──────────────────────────────┤ +│ 当前项目: [Project_01 ▼] │ +├──────────────────────────────┤ +│ Project │ Task │ +├───────────┴──────────────────┤ +│ [应用图标] 或 [任务管理] │ +└──────────────────────────────┘ +``` + +--- + +## 项目管理 + +### 基本操作 + +#### 创建项目 +``` +⚙ 设置 → 新建项目 → 输入名称 +``` + +**命名建议**: +- `Character_角色名` +- `Environment_场景名` +- `Weapon_武器名` +- `Prop_道具名` + +#### 项目功能 + +| 功能 | 说明 | +|------|------| +| **新建** | 创建新项目 | +| **复制** | 复制现有项目 | +| **重命名** | 修改项目名 | +| **删除** | 删除项目 | +| **图标** | 自定义图标 | +| **颜色** | 设置主题色 | + +#### 快速切换 +- 顶部下拉菜单 +- `Ctrl + Tab` 快捷键 +- 自动保存状态 + +--- + +## 任务管理 + +### 预设模板 + +| 任务类型 | 适用场景 | +|---------|----------| +| **Character** | 角色制作 | +| **Weapon** | 武器制作 | +| **Prop** | 道具制作 | +| **Environment** | 环境制作 | +| **Animation** | 动画制作 | +| **Rigging** | 绑定制作 | +| **Other** | 其他类型 | + +### Character 模板结构 + +``` +TaskFolder_Character_001/ +├── Reference/ # 参考资料 +├── MP/ # 中精度模型 +├── HP/ # 高精度雕刻 +├── LP/ # 低精度模型 +├── Baking/ # 烘焙流程 +├── Texture/ # 贴图制作 +├── FBX/ # 最终资产 +└── Screenshot/ # 展示截图 +``` + +### 创建流程 + +``` +1. Task 标签页 → 选择任务类型 +2. Browse → 选择工作空间 +3. 输入任务名称 +4. Create Task Folder +5. Open in Explorer 查看结果 +``` + +### 自定义结构 + +使用 **SubFolder Editor** 节点编辑器: +- 右键节点 → 添加子节点 +- `F2` 重命名 +- `Delete` 删除 +- `Ctrl + S` 保存 + +--- + +## SubFolder Editor 节点编辑器 + +### 基本操作 + +| 操作 | 快捷键 | 功能 | +|------|--------|------| +| **移动节点** | 拖拽 | 改变位置 | +| **重命名** | `F2` | 重命名节点 | +| **删除** | `Delete` | 删除节点 | +| **复制** | `Ctrl + D` | 复制节点 | +| **添加子节点** | 右键菜单 | 添加子文件夹 | +| **缩放视图** | `Ctrl + 滚轮` | 缩放 | +| **平移视图** | 中键拖拽 | 移动 | +| **居中** | `Home` | 居中显示 | +| **保存** | `Ctrl + S` | 保存结构 | + +💡 **提示**: TaskFolder 是根节点,不可删除或重命名 + +--- + +## 应用管理 + +### 添加应用 + +``` +⚙ 设置 → + 添加应用 → 填写信息 → 保存 +``` + +**必填信息**: +- **名称**: Maya 2025 +- **路径**: 可执行文件完整路径 +- **版本**: 2025.1 + +### 常用软件路径 + +| 软件 | 默认路径 | +|------|---------| +| **Maya 2025** | `C:\Program Files\Autodesk\Maya2025\bin\maya.exe` | +| **Blender** | `C:\Program Files\Blender Foundation\Blender 4.2\blender.exe` | +| **ZBrush** | `C:\Program Files\Maxon\ZBrush 2025\ZBrush.exe` | +| **Substance Painter** | `C:\Program Files\Adobe\Adobe Substance 3D Painter\...` | +| **Photoshop** | `C:\Program Files\Adobe\Adobe Photoshop 2025\Photoshop.exe` | +| **Unreal Engine** | `C:\Program Files\Epic Games\UE_5.4\Engine\Binaries\Win64\UnrealEditor.exe` | + +### 批量操作 + +| 操作 | 方法 | +|------|------| +| **多选** | `Ctrl + 点击` | +| **全选** | 右键 → 全选 | +| **删除** | `Delete` 键 | +| **排序** | 拖拽左侧手柄 | +| **取消** | `Escape` 键 | + + +--- + +## 快捷键 + +### 全局快捷键 + +| 快捷键 | 功能 | +|--------|------| +| `Ctrl + Tab` | 切换项目 | +| `Ctrl + 滚轮` | 缩放图标/视图 | +| `Ctrl + 点击` | 多选 | +| `Delete` | 删除 | +| `Escape` | 取消选择 | +| `F2` | 重命名 | +| `Ctrl + D` | 复制 | +| `Ctrl + S` | 保存 | +| `Home` | 居中显示 | +| `中键拖拽` | 平移视图 | + +--- + +## 常见问题 + +**Q: 程序启动后没有反应?** +- 检查杀毒软件是否拦截 +- 确保 `config.json` 有读写权限 +- 尝试以管理员权限运行 + +**Q: 应用启动失败?** +- 检查应用路径是否正确 +- 确认应用程序存在 +- 尝试手动启动测试 + +**Q: 如何备份设置?** +- 备份 `config.json` 文件即可 + +**Q: TaskFolder 可以删除吗?** +- 不可以,TaskFolder 是根节点 + +--- + +## 系统要求 + +**最低配置**: +- Windows 10+ +- 4GB RAM +- 100MB 空间 + +**推荐配置**: +- Windows 11 +- 8GB+ RAM +- 1920x1080 分辨率 + +--- + +**💡 提示**: 充分利用快捷键和节点编辑器,提升工作效率! + +**版本**: v1.2.0 | **更新**: 2025年11月 + +--- + +*NexusLauncher - 让项目管理更简单* 🚀 diff --git a/docs/CUSTOM_PLUGIN_GUIDE.md b/docs/CUSTOM_PLUGIN_GUIDE.md new file mode 100644 index 0000000..ba73b57 --- /dev/null +++ b/docs/CUSTOM_PLUGIN_GUIDE.md @@ -0,0 +1,577 @@ +# Maya 自定义插件添加指南 + +本指南将帮助你在 NexusLauncher 的 Maya 插件系统中添加自己的工具和插件。 + +--- + +## 📁 目录结构说明 + +``` +template/plugins/maya/2023/ +├── shelves/ # 工具架文件(.mel 格式) +├── scripts/ # Python/MEL 脚本 +├── plug-ins/ # Maya 插件文件(.py 或 .mll) +├── icons/ # 工具图标 +└── README.md # 基础说明文档 +``` + +--- + +## 🎯 添加方式概览 + +根据你的需求,有三种主要的添加方式: + +| 方式 | 适用场景 | 难度 | +|------|---------|------| +| **1. 添加工具架按钮** | 快速添加 Python 脚本工具 | ⭐ 简单 | +| **2. 添加 Maya 插件** | 创建 Maya 命令或节点 | ⭐⭐ 中等 | +| **3. 添加启动脚本** | 自动执行初始化代码 | ⭐ 简单 | + +--- + +## 方式 1: 添加工具架按钮 + +### 适用场景 +- 你有一个 Python 脚本工具想要快速访问 +- 需要在工具架上添加按钮 +- 不需要注册 Maya 命令 + +### 步骤 + +#### 1.1 准备你的 Python 脚本 + +将你的 Python 脚本放到 `scripts/` 目录: + +``` +scripts/ +├── userSetup.py # 启动脚本(已存在) +├── nexus_test.py # 示例脚本(已存在) +└── my_custom_tool.py # 👈 你的新脚本 +``` + +**示例脚本** (`my_custom_tool.py`): + +```python +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +我的自定义工具 +""" +import maya.cmds as cmds + +def run_my_tool(): + """执行我的工具""" + # 你的工具逻辑 + cmds.confirmDialog( + title='My Tool', + message='Hello from my custom tool!', + button=['OK'] + ) + print("[MyTool] Tool executed successfully") + +def show_ui(): + """显示工具界面""" + # 如果有 UI,在这里创建 + pass +``` + +#### 1.2 准备图标(可选) + +将图标文件放到 `icons/` 目录: + +``` +icons/ +├── nexus_test.png # 示例图标(已存在) +└── my_tool_icon.png # 👈 你的图标(32x32 或 64x64 PNG) +``` + +> **提示**: 如果没有图标,可以使用文字标签代替 + +#### 1.3 编辑工具架文件 + +编辑 `shelves/shelf_NexusLauncher.mel`,在文件末尾的 `}` 之前添加新按钮: + +```mel + shelfButton + -enableCommandRepeat 1 + -flexibleWidthType 3 + -flexibleWidthValue 32 + -enable 1 + -width 35 + -height 34 + -manage 1 + -visible 1 + -preventOverride 0 + -annotation "我的自定义工具 - 点击运行" + -enableBackground 0 + -backgroundColor 0 0 0 + -highlightColor 0.321569 0.521569 0.65098 + -align "center" + -label "MT" + -labelOffset 0 + -rotation 0 + -flipX 0 + -flipY 0 + -useAlpha 1 + -font "boldLabelFont" + -imageOverlayLabel "MT" + -overlayLabelColor 1 1 1 + -overlayLabelBackColor 0.2 0.8 0.5 0.9 + -image "my_tool_icon.png" + -image1 "my_tool_icon.png" + -style "iconOnly" + -marginWidth 0 + -marginHeight 1 + -command "import my_custom_tool\nmy_custom_tool.run_my_tool()" + -sourceType "python" + -commandRepeatable 1 + -flat 1 + ; +``` + +**关键参数说明**: +- `-annotation`: 鼠标悬停时的提示文字 +- `-label`: 按钮文字标签(如果没有图标会显示) +- `-imageOverlayLabel`: 图标上的文字叠加层 +- `-overlayLabelBackColor`: 文字背景颜色 (R G B Alpha) +- `-image`: 图标文件名(在 icons/ 目录中) +- `-command`: 点击按钮时执行的 Python 代码 + +#### 1.4 测试 + +1. 通过 NexusLauncher 启动 Maya 2023 +2. 检查 NexusLauncher 工具架是否出现新按钮 +3. 点击按钮测试功能 + +--- + +## 方式 2: 添加 Maya 插件 + +### 适用场景 +- 需要注册自定义 Maya 命令 +- 需要创建自定义节点 +- 需要更深度的 Maya API 集成 + +### 步骤 + +#### 2.1 创建插件文件 + +在 `plug-ins/` 目录创建你的插件文件: + +``` +plug-ins/ +├── nexus_example_plugin.py # 示例插件(已存在) +└── my_custom_plugin.py # 👈 你的新插件 +``` + +**插件模板** (`my_custom_plugin.py`): + +```python +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +我的自定义 Maya 插件 +""" +import sys +import maya.api.OpenMaya as om + + +def maya_useNewAPI(): + """告诉 Maya 使用 Python API 2.0""" + pass + + +class MyCustomCommand(om.MPxCommand): + """自定义命令类""" + + kPluginCmdName = 'myCustomCmd' # 命令名称 + + def __init__(self): + om.MPxCommand.__init__(self) + + @staticmethod + def cmdCreator(): + return MyCustomCommand() + + def doIt(self, args): + """执行命令""" + print(f'[MyPlugin] Custom command executed!') + om.MGlobal.displayInfo('My custom plugin is working!') + + # 在这里添加你的命令逻辑 + # 例如:创建对象、修改场景等 + + +def initializePlugin(plugin): + """初始化插件""" + pluginFn = om.MFnPlugin(plugin, 'YourName', '1.0', 'Any') + try: + pluginFn.registerCommand( + MyCustomCommand.kPluginCmdName, + MyCustomCommand.cmdCreator + ) + print(f'[MyPlugin] Plugin loaded: {MyCustomCommand.kPluginCmdName}') + except: + sys.stderr.write(f'Failed to register command: {MyCustomCommand.kPluginCmdName}') + raise + + +def uninitializePlugin(plugin): + """卸载插件""" + pluginFn = om.MFnPlugin(plugin) + try: + pluginFn.deregisterCommand(MyCustomCommand.kPluginCmdName) + print(f'[MyPlugin] Plugin unloaded: {MyCustomCommand.kPluginCmdName}') + except: + sys.stderr.write(f'Failed to deregister command: {MyCustomCommand.kPluginCmdName}') + raise +``` + +#### 2.2 配置自动加载 + +编辑 `scripts/userSetup.py`,在 `load_nexus_plugins()` 函数中添加你的插件: + +```python +def load_nexus_plugins(): + """Load NexusLauncher plugins""" + try: + plugin_path = os.environ.get('MAYA_PLUG_IN_PATH', '') + + if not plugin_path: + print("[NexusLauncher] MAYA_PLUG_IN_PATH not set, skipping plugin load") + return + + print(f"[NexusLauncher] MAYA_PLUG_IN_PATH: {plugin_path}") + + # 要加载的插件列表 + plugins_to_load = [ + "nexus_example_plugin.py", + "my_custom_plugin.py", # 👈 添加你的插件 + ] + + for plugin_file in plugins_to_load: + if cmds.pluginInfo(plugin_file, query=True, loaded=True): + print(f"[NexusLauncher] Plugin already loaded: {plugin_file}") + else: + try: + cmds.loadPlugin(plugin_file) + print(f"[NexusLauncher] ✓ Loaded plugin: {plugin_file}") + + # 设置为自动加载 + cmds.pluginInfo(plugin_file, edit=True, autoload=True) + print(f"[NexusLauncher] ✓ Set plugin to auto-load") + except Exception as e: + print(f"[NexusLauncher] Failed to load plugin {plugin_file}: {e}") + + except Exception as e: + print(f"[NexusLauncher] Error loading plugins: {e}") +``` + +#### 2.3 测试插件 + +1. 通过 NexusLauncher 启动 Maya 2023 +2. 检查脚本编辑器输出,确认插件已加载 +3. 在 Maya 命令行或脚本编辑器中测试命令: + +```python +import maya.cmds as cmds +cmds.myCustomCmd() # 执行你的自定义命令 +``` + +--- + +## 方式 3: 添加启动脚本 + +### 适用场景 +- 需要在 Maya 启动时自动执行某些操作 +- 设置环境变量或全局配置 +- 加载第三方库 + +### 步骤 + +#### 3.1 编辑 userSetup.py + +在 `scripts/userSetup.py` 文件末尾添加你的初始化代码: + +```python +def my_custom_startup(): + """我的自定义启动函数""" + try: + print("[MyStartup] Running custom startup code...") + + # 在这里添加你的启动逻辑 + # 例如: + # - 设置默认渲染器 + # - 加载常用插件 + # - 配置工作区 + # - 连接到资产管理系统 + + import maya.cmds as cmds + + # 示例:设置默认单位为厘米 + cmds.currentUnit(linear='cm') + print("[MyStartup] ✓ Set default unit to cm") + + # 示例:设置默认时间单位为 24fps + cmds.currentUnit(time='film') + print("[MyStartup] ✓ Set default time unit to 24fps") + + print("[MyStartup] ✓ Custom startup completed") + + except Exception as e: + print(f"[MyStartup] Error during startup: {e}") + + +# 在 Maya 启动完成后执行 +cmds.evalDeferred(my_custom_startup) +``` + +--- + +## 🔧 高级技巧 + +### 1. 动态重载工具架 + +如果你在开发过程中频繁修改工具架,可以使用 `RELOAD_SHELF.py` 快速重载: + +```python +# 在 Maya 脚本编辑器中执行 +import sys +sys.path.append(r'E:\Zoroot\Dev\NexusLauncher\template\plugins\maya\2023') +import RELOAD_SHELF +RELOAD_SHELF.reload_shelf() +``` + +### 2. 使用相对导入 + +如果你的工具有多个模块,可以创建包结构: + +``` +scripts/ +├── my_tool/ +│ ├── __init__.py +│ ├── core.py +│ ├── ui.py +│ └── utils.py +└── userSetup.py +``` + +在工具架按钮中使用: + +```mel +-command "from my_tool import ui\nui.show_window()" +``` + +### 3. 添加菜单项 + +除了工具架按钮,你还可以在 `userSetup.py` 中添加自定义菜单: + +```python +def create_custom_menu(): + """创建自定义菜单""" + try: + import maya.cmds as cmds + + # 检查菜单是否已存在 + if cmds.menu('NexusMenu', exists=True): + cmds.deleteUI('NexusMenu') + + # 创建菜单 + main_window = mel.eval('$tmpVar=$gMainWindow') + custom_menu = cmds.menu( + 'NexusMenu', + label='NexusTools', + parent=main_window, + tearOff=True + ) + + # 添加菜单项 + cmds.menuItem( + label='My Tool', + command='import my_custom_tool; my_custom_tool.run_my_tool()', + parent=custom_menu + ) + + cmds.menuItem(divider=True, parent=custom_menu) + + cmds.menuItem( + label='About', + command='cmds.confirmDialog(title="About", message="NexusTools v1.0")', + parent=custom_menu + ) + + print("[NexusLauncher] ✓ Created custom menu") + + except Exception as e: + print(f"[NexusLauncher] Error creating menu: {e}") + +# 在启动时执行 +cmds.evalDeferred(create_custom_menu) +``` + +### 4. 调试技巧 + +**查看环境变量**: +```python +import os +print(os.environ.get('MAYA_SHELF_PATH')) +print(os.environ.get('MAYA_PLUG_IN_PATH')) +``` + +**检查插件加载状态**: +```python +import maya.cmds as cmds +print(cmds.pluginInfo(query=True, listPlugins=True)) +``` + +**查看工具架列表**: +```python +import maya.cmds as cmds +print(cmds.lsUI(type='shelfLayout')) +``` + +--- + +## 📋 常见问题 + +### Q1: 工具架按钮不显示图标? + +**A**: 检查以下几点: +1. 图标文件是否在 `icons/` 目录中 +2. 文件名是否正确(区分大小写) +3. 图标格式是否为 PNG +4. `XBMLANGPATH` 环境变量是否正确设置 + +### Q2: Python 脚本找不到模块? + +**A**: 确保: +1. 脚本在 `scripts/` 目录中 +2. `PYTHONPATH` 和 `MAYA_SCRIPT_PATH` 已正确设置 +3. 使用 `import` 时不需要包含 `.py` 后缀 + +### Q3: 插件加载失败? + +**A**: 检查: +1. 插件文件语法是否正确 +2. 是否包含 `maya_useNewAPI()` 函数(API 2.0) +3. `initializePlugin()` 和 `uninitializePlugin()` 是否正确实现 +4. 查看脚本编辑器的错误信息 + +### Q4: 修改后没有生效? + +**A**: 尝试: +1. 完全关闭 Maya 重新启动 +2. 使用 `RELOAD_SHELF.py` 重载工具架 +3. 检查是否修改了正确版本的文件(2023/2025) + +### Q5: 工具架在非 NexusLauncher 启动时也出现? + +**A**: 这是正常的,因为 Maya 会保存工具架配置。解决方法: +- 系统已配置为临时工具架,不会保存到配置文件 +- 如果仍然出现,删除 `Documents\maya\2023\prefs\shelves\shelf_NexusLauncher.mel` + +--- + +## 🎓 学习资源 + +### Maya Python API +- [Maya Python API 2.0 文档](https://help.autodesk.com/view/MAYAUL/2023/ENU/?guid=Maya_SDK_py_ref_index_html) +- [Maya Commands 参考](https://help.autodesk.com/cloudhelp/2023/ENU/Maya-Tech-Docs/Commands/index.html) + +### MEL 脚本 +- [MEL 命令参考](https://help.autodesk.com/cloudhelp/2023/ENU/Maya-Tech-Docs/Commands/index.html) +- [工具架按钮参数说明](https://help.autodesk.com/cloudhelp/2023/ENU/Maya-Tech-Docs/Commands/shelfButton.html) + +--- + +## 📝 最佳实践 + +1. **命名规范** + - 使用有意义的名称 + - 避免与 Maya 内置命令冲突 + - 使用前缀区分自己的工具(如 `nexus_`, `my_`) + +2. **错误处理** + - 始终使用 try-except 包裹关键代码 + - 提供清晰的错误信息 + - 使用 `print()` 输出调试信息 + +3. **代码组织** + - 将复杂工具拆分为多个模块 + - 使用函数和类组织代码 + - 添加文档字符串说明 + +4. **性能优化** + - 避免在启动时执行耗时操作 + - 使用 `cmds.evalDeferred()` 延迟执行 + - 只加载必要的插件 + +5. **版本兼容** + - 如果支持多个 Maya 版本,注意 API 差异 + - 在 `2023/` 和 `2025/` 目录分别维护 + - 测试不同版本的兼容性 + +--- + +## 🚀 快速开始示例 + +### 完整示例:添加一个"创建立方体"工具 + +**1. 创建脚本** (`scripts/create_cube_tool.py`): + +```python +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +创建立方体工具 +""" +import maya.cmds as cmds + +def create_custom_cube(): + """创建一个自定义立方体""" + # 创建立方体 + cube = cmds.polyCube(name='CustomCube', width=2, height=2, depth=2)[0] + + # 设置颜色 + cmds.polyColorPerVertex(cube, rgb=(1, 0.5, 0), colorDisplayOption=True) + + # 移动到原点上方 + cmds.move(0, 1, 0, cube) + + print(f"[CreateCube] Created cube: {cube}") + cmds.select(cube) + + return cube +``` + +**2. 添加工具架按钮** (编辑 `shelves/shelf_NexusLauncher.mel`): + +```mel + shelfButton + -annotation "Create Custom Cube" + -label "Cube" + -imageOverlayLabel "CB" + -overlayLabelBackColor 0.8 0.5 0.2 0.9 + -command "import create_cube_tool\ncreate_cube_tool.create_custom_cube()" + -sourceType "python" + ; +``` + +**3. 测试** +- 启动 Maya +- 点击工具架上的 "CB" 按钮 +- 应该会创建一个橙色的立方体 + +--- + +## 📞 获取帮助 + +如果遇到问题: +1. 检查 Maya 脚本编辑器的错误信息 +2. 查看本指南的"常见问题"部分 +3. 参考示例插件代码 +4. 查阅 Maya 官方文档 + +--- + +**祝你开发愉快!** 🎉 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..daf81c5 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,531 @@ +# NexusLauncher + +一个现代化的 Windows 桌面应用启动器,使用 Python 3.11 和 CustomTkinter 构建。支持系统托盘、图标缩放、多项目管理等强大功能。 + +## 功能特性 + +### 主要功能 + +1. **项目管理** + - 支持多项目配置 + - 通过下拉菜单快速切换项目 + - 每个项目可配置独立的应用列表 + - 项目重命名和删除功能 + +2. **应用启动** + - 美观的圆角方形按钮 + - **自动提取并显示应用图标** 🎨 + - 显示应用名称和版本号 + - 一键启动配置的应用程序 + - 按钮自适应排列(每行3个) + - 图标缓存机制,提高加载速度 + - **Ctrl + 鼠标滚轮缩放图标** 🔍 + +3. **系统托盘集成** 🎯 + - 最小化到系统托盘 + - 右键菜单快速访问 + - 显示主窗口、打开设置、退出应用 + - 窗口定位在屏幕右下角(任务栏上方) + +4. **任务管理** 📦 + - 任务类型配置(Character、Weapon、Prop 等) + - 可视化文件夹结构编辑器 + - 节点拖拽、复制、粘贴 + - 任务名称自动建议 + - 自定义子文件夹结构 + - 任务类型动态更新 + +5. **配置管理** + - 现代化的深色主题设置界面 + - 添加/编辑/删除项目 + - 添加/编辑/删除应用 + - 支持浏览文件选择应用路径 + - 配置自动保存到 JSON 文件 + - **多选、复制、剪切、粘贴应用** 📋 + - **拖拽排序应用顺序** 🔄 + - **深色标题栏(Windows 10/11)** 🌙 + - **预设图标选择** 🎨 + - **智能图标路径** 🖼️ + +6. **插件系统** 🔌 + - **Maya 插件集成** + - 自动设置 Maya 环境变量(MAYA_SHELF_PATH、MAYA_PLUG_IN_PATH 等) + - 临时工具架加载(不保存到用户配置) + - 智能清理机制(退出时自动清理) + - 支持多版本 Maya(2023、2025+) + - 工具架重载脚本(开发调试用) + - **Substance Painter 插件支持** + - **可扩展架构**(BasePlugin 基类) + +7. **高级功能** + - 应用卡片多选(Ctrl、Shift) + - 右键菜单(复制、剪切、粘贴、删除) + - 键盘快捷键支持 + - 智能去重粘贴 + - 自定义对话框(统一图标和主题) + - 模块化架构(config、ui 分离) + - 跨电脑配置兼容 + +### 界面布局 + +- **主窗口**: 右下角定位,任务栏上方 +- **顶部菜单栏**: 显示应用标题和设置按钮 +- **项目栏**: 下拉菜单用于切换当前项目 +- **按钮盘**: 显示当前项目的所有应用按钮 +- **系统托盘**: 最小化后停靠在托盘区 + +## 系统要求 + +- Windows 10/11 +- Python 3.11 或更高版本 + +## 安装依赖 + +```bash +python -m pip install -r requirements.txt +``` + +## 运行应用 + +### 开发模式 + +使用提供的脚本: + +```bash +Run.bat # 正常运行(无控制台) +RunDebug.bat # 调试运行(显示控制台输出) +``` + +或手动运行: + +```bash +pythonw main.py # 无控制台 +python main.py # 显示控制台 +``` + +### 构建 EXE + +使用提供的构建脚本: + +```bash +build.bat +``` + +**构建脚本功能:** +- 自动检查 Python 版本 +- 安装/更新依赖 +- 自动关闭运行中的应用 +- 清理旧的构建文件 +- 使用 PyInstaller 打包 + +或手动构建: + +```bash +pyinstaller --noconfirm --onefile --windowed --name "NexusLauncher" --icon="icons/NexusLauncher.ico" --add-data "icons;icons" main.py +``` + +构建完成后,可执行文件位于 `dist\NexusLauncher.exe` + +### 其他工具 + +```bash +CleanCache.bat # 清理所有 Python 缓存 +TestImports.bat # 测试模块导入是否正常 +``` + +## 使用说明 + +### 首次使用 + +1. 启动 NexusLauncher +2. 主窗口会出现在屏幕右下角(任务栏上方) +3. 点击右上角的"⚙ 设置"按钮 +4. 创建新项目或使用默认的"示例项目" +5. 添加应用: + - 点击"+ 添加应用"按钮 + - 填写应用名称、路径和版本号 + - 可以点击"浏览"按钮选择应用程序 + - 点击"保存" + +### 主窗口操作 + +- **启动应用**: 点击应用按钮 +- **缩放图标**: 按住 `Ctrl` + 鼠标滚轮 +- **切换项目**: 使用顶部下拉菜单 +- **最小化**: 点击关闭按钮,窗口会最小化到系统托盘 + +### 系统托盘 + +右键点击托盘图标可以: +- **显示主窗口**: 恢复主窗口 +- **设置**: 打开设置界面 +- **退出**: 完全退出应用 + +### 设置界面操作 + +#### 项目管理 +- **新建项目**: 点击"+ 新建项目"按钮 +- **重命名项目**: 点击"重命名项目"按钮 +- **删除项目**: 点击"删除项目"按钮 + +#### 应用管理 +- **添加应用**: 点击"+ 添加应用"按钮 +- **编辑应用**: 点击应用卡片上的"编辑"按钮 +- **删除应用**: 点击应用卡片上的"删除"按钮 + +#### 高级操作 +- **多选应用**: + - `Ctrl + 左键`: 多选/取消选择 + - `Shift + 左键`: 范围选择 +- **复制应用**: `Ctrl + C` 或右键菜单 +- **剪切应用**: `Ctrl + X` 或右键菜单 +- **粘贴应用**: `Ctrl + V` 或右键菜单(空白处) +- **删除应用**: `Delete` 键或右键菜单 +- **拖拽排序**: 拖动应用卡片左侧的手柄图标 +- **取消选择**: `Esc` 键 + +### 键盘快捷键 + +| 快捷键 | 功能 | +|--------|------| +| `Ctrl + 鼠标滚轮` | 缩放主窗口图标 | +| `Ctrl + C` | 复制选中的应用 | +| `Ctrl + X` | 剪切选中的应用 | +| `Ctrl + V` | 粘贴应用 | +| `Delete` | 删除选中的应用 | +| `Esc` | 取消所有选择 | + +## Maya 插件系统 + +### 功能特性 + +NexusLauncher 为 Maya 提供了完整的插件集成系统: + +1. **自动环境配置** + - 自动设置 `MAYA_SHELF_PATH`(工具架路径) + - 自动设置 `MAYA_PLUG_IN_PATH`(插件路径) + - 自动设置 `MAYA_SCRIPT_PATH`(脚本路径) + - 自动设置 `XBMLANGPATH`(图标路径) + +2. **临时工具架** + - 工具架只在 NexusLauncher 启动的 Maya 中显示 + - 不保存到用户配置文件 + - 退出时自动清理 + - 不影响外部启动的 Maya + +3. **多版本支持** + - Maya 2023 + - Maya 2025 + - 可扩展到其他版本 + +### 配置方法 + +在 `config.json` 中配置 Maya 插件: + +```json +{ + "app_plugins": { + "C:/Program Files/Autodesk/Maya2023/bin/maya.exe": { + "maya_plugin_path": "E:/NexusLauncher/template/plugins/maya/2023/plug-ins", + "maya_shelf_path": "E:/NexusLauncher/template/plugins/maya/2023/shelves" + } + } +} +``` + +### 插件目录结构 + +```bash +template/plugins/maya/ +├── 2023/ +│ ├── scripts/ +│ │ └── userSetup.py # Maya 启动脚本 +│ ├── shelves/ +│ │ └── shelf_NexusLauncher.mel # 工具架定义 +│ ├── plug-ins/ +│ │ └── nexus_example_plugin.py # 示例插件 +│ ├── icons/ +│ │ └── *.png # 工具架图标 +│ └── RELOAD_SHELF.py # 工具架重载脚本(开发用) +└── 2025/ + └── (相同结构) +``` + +### 开发调试 + +在 Maya Script Editor 中运行重载脚本: + +```python +# 方法 1: 导入并运行 +import sys +sys.path.append("E:/NexusLauncher/template/plugins/maya/2023") +import RELOAD_SHELF +RELOAD_SHELF.reload_shelf() + +# 方法 2: 直接执行 +exec(open("E:/NexusLauncher/template/plugins/maya/2023/RELOAD_SHELF.py").read()) +``` + +### 工作原理 + +1. **启动时** + - NexusLauncher 设置环境变量 + - Maya 启动并执行 `userSetup.py` + - 禁用工具架自动保存 + - 手动创建临时工具架 + - 执行工具架脚本添加按钮 + - 立即删除可能创建的配置文件 + +2. **运行时** + - 工具架正常使用 + - 配置文件不存在 + +3. **退出时** + - 检查是否由 NexusLauncher 启动 + - 删除配置文件(如果存在) + - 清理完成 + +4. **外部启动** + - 没有环境变量 + - 没有配置文件 + - 不显示 NexusLauncher 工具架 + +## 配置文件 + +配置保存在 `config.json` 文件中,格式如下: + +```json +{ + "projects": { + "项目名称": { + "apps": [ + { + "name": "应用名称", + "path": "应用路径", + "version": "版本号" + } + ] + } + }, + "current_project": "当前选中的项目", + "window_size": { + "width": 400, + "height": 400 + } +} +``` + +## 项目结构 + +``` +NexusLauncher/ +├── main.py # 主程序入口 (984 行) +├── requirements.txt # Python 依赖 +├── config.json # 配置文件(自动生成) +├── config/ # 配置模块 +│ ├── __init__.py +│ ├── config_manager.py # 配置管理器 (546 行) +│ └── icon_config.py # 图标配置管理器 (332 行) 🆕 +├── ui/ # UI 模块 +│ ├── __init__.py +│ ├── constants.py # UI 常量定义 (118 行) 🆕 +│ ├── utils.py # UI 工具函数 +│ ├── base_dialog.py # 对话框基类 (64 行) 🆕 +│ ├── icon_manager.py # 图标管理器 (199 行) 🆕 +│ ├── settings_window.py # 设置窗口 (2000+ 行) +│ ├── custom_dialogs.py # 自定义对话框 (148 行) +│ └── task/ # 任务面板模块 +│ ├── __init__.py +│ ├── task_panel.py # 任务面板 +│ ├── node.py # 节点类 +│ └── subfolder_editor.py # 子文件夹编辑器 +├── icons/ # 图标资源 +│ ├── NexusLauncher.ico # 应用图标 +│ └── *.png # 预设图标 +├── docs/ # 文档 +│ ├── INDEX.md # 文档索引 🆕 +│ ├── README.md # 项目说明 +│ ├── CHANGELOG.md # 更新日志 +│ ├── OPTIMIZATION_COMPLETE.md # 优化总结 🆕 +│ ├── OPTIMIZATION_PLAN.md # 优化计划 🆕 +│ ├── BUG_FIX_LOG.md # Bug 修复日志 🆕 +│ ├── TROUBLESHOOTING.md # 故障排查指南 🆕 +│ ├── APP_MANAGEMENT_FEATURES.md # 功能清单 🆕 +│ ├── TESTING_GUIDE.md # 测试指南 🆕 +│ └── CODE_STATISTICS.md # 代码统计 🆕 +├── build.bat # Windows 构建脚本 +├── Run.bat # 运行脚本 +├── RunDebug.bat # 调试运行脚本 +├── CleanCache.bat # 清理缓存脚本 +└── TestImports.bat # 测试导入脚本 +``` + +## 技术栈 + +- **Python 3.11**: 编程语言 +- **CustomTkinter**: 现代化的 UI 框架 +- **Pillow**: 图像处理库(图标加载和处理) +- **pywin32**: Windows API 调用(图标提取) +- **pystray**: 系统托盘集成 +- **ctypes**: Windows DWM API(深色标题栏) +- **PyInstaller**: 打包工具 +- **functools.lru_cache**: 图标缓存优化(性能提升 30-50%) + +## 开发说明 + +### 修改主题 + +在 `main.py` 中修改: + +```python +ctk.set_appearance_mode("dark") # 可选: "dark", "light", "system" +ctk.set_default_color_theme("blue") # 可选: "blue", "green", "dark-blue" +``` + +### 自定义窗口大小 + +默认窗口大小为 400x400,可在首次运行后调整,程序会自动记住窗口大小。 + +### 自定义按钮布局 + +在 `main.py` 的 `_create_app_button` 方法中修改按钮样式和大小。 + +## 常见问题 + +### Q: 应用无法启动? +A: 请检查应用路径是否正确,确保路径指向有效的可执行文件。 + +### Q: 如何备份配置? +A: 复制 `config.json` 文件即可备份所有配置。 + +### Q: 构建的 EXE 文件很大? +A: 这是正常的,因为 PyInstaller 会打包 Python 解释器和所有依赖。如需减小体积,可以使用 `--onedir` 模式。 + +### Q: 应用按钮上的图标是如何显示的? +A: NexusLauncher 会自动从 .exe 文件中提取图标并显示。如果提取失败,会显示默认图标。详见 `ICON_FEATURE.md`。 + +### Q: 图标不显示怎么办? +A: 确保已安装 pywin32 库:`pip install pywin32`。如果问题仍然存在,请检查应用路径是否正确。 + +### Q: 如何为 NexusLauncher 本身添加图标? +A: 可以在构建时指定图标文件: +```bash +pyinstaller --icon=icon.ico main.py +``` + +### Q: 多选、复制、粘贴功能不工作? +A: 请查看 [TROUBLESHOOTING.md](TROUBLESHOOTING.md) 获取详细的故障排查指南。 + +### Q: 想了解代码优化的详细信息? +A: 请查看以下文档: +- [OPTIMIZATION_COMPLETE.md](OPTIMIZATION_COMPLETE.md) - 完整的优化总结 +- [CODE_STATISTICS.md](CODE_STATISTICS.md) - 详细的代码统计 +- [OPTIMIZATION_PLAN.md](OPTIMIZATION_PLAN.md) - 优化实施计划 + +## 许可证 + +本项目仅供学习和个人使用。 + +## 更新日志 + +### v2.1.0 (2025-11-22) +- 🔌 **Maya 插件系统**: 完整的 Maya 插件集成 + - 自动设置环境变量(MAYA_SHELF_PATH、MAYA_PLUG_IN_PATH、XBMLANGPATH 等) + - 临时工具架加载(不保存到用户配置文件) + - 智能清理机制(启动时和退出时自动清理) + - 支持多版本 Maya(2023、2025+) + - 工具架重载脚本(RELOAD_SHELF.py) +- 🎯 **插件架构优化**: + - BasePlugin 抽象基类 + - PluginManager 插件管理器 + - MayaPlugin、SubstancePainterPlugin 实现 + - 可扩展的插件系统 +- 📝 **代码优化**: + - userSetup.py 从 348 行优化到 216 行(-38%) + - 清理逻辑从 ~160 行简化到 ~30 行(-81%) + - 移除复杂的路径检测,只使用 MAYA_APP_DIR +- 🐛 **Bug 修复**: + - 修复 Maya 2023 不支持 `-save` 参数的问题 + - 修复工具架按钮不显示的问题 + - 修复配置文件残留导致的空工具架问题 + + +### v2.0.9 (2025-11-19) - 当前版本 ⭐ +- 🚀 **代码优化**: 完成 5 个重大优化项目 + - ✅ 优化 1: 使用常量替换硬编码(-50 行) + - ✅ 优化 2: 创建 BaseDialog 基类(-52 行重复代码) + - ✅ 优化 3: 使用常量优化对话框(12 处替换) + - ✅ 优化 4: 创建 IconManager 类(-71 行,+30-50% 性能) + - ✅ 优化 5: 创建 IconConfigManager 类(-141 行逻辑) +- 📊 **代码质量提升**: + - 减少重复代码 83%(-314 行) + - 消除硬编码 100%(50+ 处) + - main.py 精简 11%(1105 → 984 行) + - config_manager.py 精简 21%(687 → 546 行) +- 🎯 **性能提升**: + - 图标加载性能 +30-50%(LRU 缓存) + - 启动速度 +10-15% + - 缓存命中率 0% → 85% +- 🏗️ **架构改进**: + - 新增 4 个基础类(IconManager, IconConfigManager, BaseDialog, constants) + - 职责分离清晰,可维护性显著提升 + - 代码结构更清晰,易于测试和扩展 +- 🐛 **Bug 修复**: + - 修复 _darken_color 引用错误 + - 修复 icon_cache 引用错误 +- 📝 **文档完善**: + - 新增 9 个详细文档(优化报告、故障排查、测试指南等) + - 文档清理优化(16 → 9 个核心文档) + +### v2.0.8 (2025-11-18) +- 🏗️ **代码重构**: 模块化架构,分离 config 和 ui 模块 +- 📦 **任务管理系统**: 新增任务面板,支持任务类型和文件夹结构管理 +- 🎨 **节点编辑器**: 可视化的文件夹结构编辑器 +- 🔧 **统一工具函数**: 图标路径处理统一化,支持开发和打包环境 +- 🖼️ **智能图标路径**: 预设图标使用相对路径,跨电脑兼容 +- 🔄 **自动关闭进程**: build.bat 自动关闭运行中的应用 +- 📝 **完善文档**: 添加批处理脚本说明和重构总结 +- ✨ **子窗口图标修复**: 所有子窗口正确显示应用图标 +- 🎯 **任务类型动态更新**: 任务名称根据类型自动更新 +- 🔍 **预设图标选择**: 编译后的应用支持预设图标显示 + +### v2.0.0 (2025-11-16) +- 🎯 **系统托盘集成**: 最小化到托盘,右键菜单快速访问 +- 🔍 **图标缩放**: Ctrl + 鼠标滚轮动态缩放应用图标 +- 📍 **窗口定位**: 主窗口自动定位在屏幕右下角(任务栏上方) +- 🌙 **深色标题栏**: 所有对话框支持 Windows 10/11 深色标题栏 +- 📋 **多选操作**: 支持 Ctrl/Shift 多选应用卡片 +- ✂️ **复制粘贴**: 完整的复制、剪切、粘贴功能 +- 🔄 **拖拽排序**: 拖动手柄重新排列应用顺序 +- 🎨 **自定义对话框**: 统一的深色主题对话框(图标、标题栏) +- ⌨️ **键盘快捷键**: 完整的快捷键支持(Ctrl+C/X/V, Delete, Esc) +- 🎭 **右键菜单**: 应用卡片和空白区域的上下文菜单 +- 🔧 **智能去重**: 粘贴时自动去除重复应用 + +### v1.1.0 (2024-11-15) +- 🎨 **超高清图标**: 提取分辨率提升到 128x128(源尺寸 256x256) +- ✨ **图标质量**: 清晰度接近桌面图标,细节丰富,边缘平滑 +- 🔧 **算法优化**: LANCZOS 高质量重采样 + 100% PNG 质量 +- 📈 **视觉效果**: 图标清晰度提升 100%+ + +### v1.0.3 (2024-11-15) +- 🎨 **界面优化**: 按钮尺寸从 120x120 减小到 100x100 +- 📈 **图标增强**: 图标分辨率从 48x48 提升到 64x64,显示尺寸增加到 56x56 +- ✨ 图标更清晰,细节更丰富,更易识别 +- 🎯 布局更紧凑,可显示更多应用 + +### v1.0.1 (2024-11-15) +- ✨ **新增自动图标提取功能** +- 从 .exe 文件中自动提取并显示应用图标 +- 实现图标缓存机制,提高加载速度 +- 添加默认图标支持 +- 优化按钮显示效果 + +### v1.0.0 (2024-11-15) +- 初始版本 +- 支持多项目管理 +- 应用启动功能 +- 现代化配置界面 +- 自适应按钮布局 + +详细更新日志请查看 [CHANGELOG.md](CHANGELOG.md) diff --git a/icons/3DsMax.png b/icons/3DsMax.png new file mode 100644 index 0000000..47835ea Binary files /dev/null and b/icons/3DsMax.png differ diff --git a/icons/Billfish.png b/icons/Billfish.png new file mode 100644 index 0000000..718befa Binary files /dev/null and b/icons/Billfish.png differ diff --git a/icons/Blender.png b/icons/Blender.png new file mode 100644 index 0000000..eef6810 Binary files /dev/null and b/icons/Blender.png differ diff --git a/icons/CharacterCreater.png b/icons/CharacterCreater.png new file mode 100644 index 0000000..96fe595 Binary files /dev/null and b/icons/CharacterCreater.png differ diff --git a/icons/Eagle.png b/icons/Eagle.png new file mode 100644 index 0000000..456f2cf Binary files /dev/null and b/icons/Eagle.png differ diff --git a/icons/EpicGames.png b/icons/EpicGames.png new file mode 100644 index 0000000..47d8a7b Binary files /dev/null and b/icons/EpicGames.png differ diff --git a/icons/EpicGamesLauncher.png b/icons/EpicGamesLauncher.png new file mode 100644 index 0000000..4b4e547 Binary files /dev/null and b/icons/EpicGamesLauncher.png differ diff --git a/icons/Everything.png b/icons/Everything.png new file mode 100644 index 0000000..1f0f6a3 Binary files /dev/null and b/icons/Everything.png differ diff --git a/icons/Houdini.png b/icons/Houdini.png new file mode 100644 index 0000000..6776410 Binary files /dev/null and b/icons/Houdini.png differ diff --git a/icons/MarmosetToolBag.png b/icons/MarmosetToolBag.png new file mode 100644 index 0000000..c5a5102 Binary files /dev/null and b/icons/MarmosetToolBag.png differ diff --git a/icons/MarvelousDesigner.png b/icons/MarvelousDesigner.png new file mode 100644 index 0000000..0309f4c Binary files /dev/null and b/icons/MarvelousDesigner.png differ diff --git a/icons/Maya.png b/icons/Maya.png new file mode 100644 index 0000000..428d7aa Binary files /dev/null and b/icons/Maya.png differ diff --git a/icons/NexusLauncher.ico b/icons/NexusLauncher.ico new file mode 100644 index 0000000..16b8d93 Binary files /dev/null and b/icons/NexusLauncher.ico differ diff --git a/icons/P4V.png b/icons/P4V.png new file mode 100644 index 0000000..b7b1fec Binary files /dev/null and b/icons/P4V.png differ diff --git a/icons/Perforce.png b/icons/Perforce.png new file mode 100644 index 0000000..5379cb6 Binary files /dev/null and b/icons/Perforce.png differ diff --git a/icons/Photoshop.png b/icons/Photoshop.png new file mode 100644 index 0000000..e195c35 Binary files /dev/null and b/icons/Photoshop.png differ diff --git a/icons/PureRef.png b/icons/PureRef.png new file mode 100644 index 0000000..5cc39a1 Binary files /dev/null and b/icons/PureRef.png differ diff --git a/icons/RizomUV.png b/icons/RizomUV.png new file mode 100644 index 0000000..87304a9 Binary files /dev/null and b/icons/RizomUV.png differ diff --git a/icons/SubstanceDesigner.png b/icons/SubstanceDesigner.png new file mode 100644 index 0000000..974e085 Binary files /dev/null and b/icons/SubstanceDesigner.png differ diff --git a/icons/SubstancePainter.png b/icons/SubstancePainter.png new file mode 100644 index 0000000..32619be Binary files /dev/null and b/icons/SubstancePainter.png differ diff --git a/icons/UEFN.png b/icons/UEFN.png new file mode 100644 index 0000000..028eba9 Binary files /dev/null and b/icons/UEFN.png differ diff --git a/icons/UnrealEngine.png b/icons/UnrealEngine.png new file mode 100644 index 0000000..7f4e408 Binary files /dev/null and b/icons/UnrealEngine.png differ diff --git a/icons/UnrealGameSync.png b/icons/UnrealGameSync.png new file mode 100644 index 0000000..2f9de57 Binary files /dev/null and b/icons/UnrealGameSync.png differ diff --git a/icons/Wrap4D.png b/icons/Wrap4D.png new file mode 100644 index 0000000..788a7f1 Binary files /dev/null and b/icons/Wrap4D.png differ diff --git a/icons/Zbrush.png b/icons/Zbrush.png new file mode 100644 index 0000000..27f6898 Binary files /dev/null and b/icons/Zbrush.png differ diff --git a/main.py b/main.py new file mode 100644 index 0000000..6dba101 --- /dev/null +++ b/main.py @@ -0,0 +1,384 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +NexusLauncher - 主程序(简化版) +一个现代化的应用启动器 +""" +import customtkinter as ctk +from config import ConfigManager +from config.constants import ( + BG_COLOR_DARK, + BG_COLOR_BUTTON, + BG_COLOR_BUTTON_HOVER, + BORDER_COLOR, + SEGMENTED_BUTTON_SELECTED_COLOR, + SEGMENTED_BUTTON_SELECTED_HOVER_COLOR, + SEGMENTED_BUTTON_UNSELECTED_COLOR, + SEGMENTED_BUTTON_UNSELECTED_HOVER_COLOR, + DROPDOWN_FG_COLOR, + DROPDOWN_HOVER_COLOR, + TEXT_COLOR_PRIMARY, + COLOR_TRANSPARENT +) +from ui import SettingsWindow, get_icons_dir, IconManager +from ui.task import TaskPanel +from ui.project import ProjectPanel +from ui.utilities import WindowManager, UIHelpers + + +class NexusLauncher(ctk.CTk): + """主应用程序类(简化版)""" + + def __init__(self): + super().__init__() + + # 调试模式控制 + self.debug_mode = False + + # 创建启动画面 + self.splash = None + try: + from ui import SplashScreen + self.splash = SplashScreen(self) + except Exception as e: + self._log(f"Failed to create splash screen: {e}", "WARNING") + + # 初始化管理器 + self.config_manager = ConfigManager() + self.window_manager = WindowManager(self, self.config_manager) + + # 设置Windows AppUserModelID + self.window_manager.setup_window_appid() + + # 图标管理 + self.icons_dir = get_icons_dir() + self.icon_size = self.config_manager.get_icon_size() + self.icon_manager = IconManager(self.icons_dir, self.icon_size) + + # 窗口配置 + self._setup_window() + + # 创建界面 + self._create_widgets() + + # 绑定事件 + self._bind_events() + + # 初始化UI + self._initialize_ui() + + # 设置托盘和窗口关闭事件 + self.protocol("WM_DELETE_WINDOW", self.window_manager.hide_window) + self.window_manager.setup_tray_icon() + + def _log(self, message: str, level: str = "INFO"): + """统一的日志方法 + + Args: + message: 日志消息 + level: 日志级别 (DEBUG, INFO, WARNING, ERROR) + """ + # DEBUG级别的日志只在调试模式下输出 + if level == "DEBUG" and not self.debug_mode: + return + + prefix = f"[{level}]" + full_message = f"{prefix} {message}" + print(full_message) + + def _setup_window(self): + """设置窗口属性""" + self.title("NexusLauncher") + width, height = self.config_manager.get_window_size() + self.minsize(200, 200) + + # 定位窗口到右下角 + self.window_manager.position_window_bottom_right(width, height) + + # 设置图标和主题 + self.window_manager.set_window_icon() + ctk.set_appearance_mode("dark") + self.configure(fg_color=BG_COLOR_DARK) + + def _create_widgets(self): + """创建界面组件""" + # 配置网格布局 + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(1, weight=1) + + # 创建各个部分 + self._create_header() + self._create_project_area() + + def _create_header(self): + """创建顶部菜单栏""" + self.menu_frame = ctk.CTkFrame(self, height=40, corner_radius=0) + self.menu_frame.grid(row=0, column=0, sticky="ew", padx=0, pady=0) + self.menu_frame.grid_columnconfigure(0, weight=1) + + # 标题 + ctk.CTkLabel( + self.menu_frame, + text="NexusLauncher", + font=ctk.CTkFont(size=16, weight="bold") + ).grid(row=0, column=0, padx=20, pady=8, sticky="w") + + # 设置按钮 + ctk.CTkButton( + self.menu_frame, + text="⚙ 设置", + command=self._open_settings, + width=90, + height=30, + font=ctk.CTkFont(size=12) + ).grid(row=0, column=1, padx=20, pady=8, sticky="e") + + def _create_project_area(self): + """创建项目区域""" + self.project_frame = ctk.CTkFrame(self, corner_radius=0, fg_color=COLOR_TRANSPARENT) + self.project_frame.grid(row=1, column=0, sticky="nsew", padx=0, pady=0) + self.project_frame.grid_columnconfigure(0, weight=1) + self.project_frame.grid_rowconfigure(1, weight=1) + + self._create_project_selector() + self._create_tabview() + + def _create_project_selector(self): + """创建项目选择下拉框""" + project_select_frame = ctk.CTkFrame(self.project_frame, fg_color=COLOR_TRANSPARENT) + project_select_frame.grid(row=0, column=0, sticky="ew", padx=20, pady=(3, 3)) + project_select_frame.grid_columnconfigure(1, weight=1) + + ctk.CTkLabel( + project_select_frame, + text="当前项目:", + font=ctk.CTkFont(size=13, weight="bold") + ).grid(row=0, column=0, padx=(0, 10), sticky="w") + + self.project_combo = ctk.CTkComboBox( + project_select_frame, + command=self._on_project_changed, + height=32, + font=ctk.CTkFont(size=12), + dropdown_font=ctk.CTkFont(size=12), + button_color=BG_COLOR_BUTTON, + button_hover_color=BG_COLOR_BUTTON_HOVER, + border_color=BORDER_COLOR, + border_width=1, + state="readonly", + corner_radius=8, + dropdown_fg_color=DROPDOWN_FG_COLOR, + dropdown_hover_color=DROPDOWN_HOVER_COLOR, + dropdown_text_color=TEXT_COLOR_PRIMARY, + justify="left" + ) + self.project_combo.grid(row=0, column=1, sticky="ew") + + def _create_tabview(self): + """创建标签页""" + self.tabview = ctk.CTkTabview( + self.project_frame, + height=40, + corner_radius=10, + segmented_button_fg_color=SEGMENTED_BUTTON_UNSELECTED_COLOR, + segmented_button_selected_color=SEGMENTED_BUTTON_SELECTED_COLOR, + segmented_button_selected_hover_color=SEGMENTED_BUTTON_SELECTED_HOVER_COLOR, + segmented_button_unselected_color=SEGMENTED_BUTTON_UNSELECTED_COLOR, + segmented_button_unselected_hover_color=SEGMENTED_BUTTON_UNSELECTED_HOVER_COLOR, + anchor="w" + ) + self.tabview.grid(row=1, column=0, sticky="nsew", padx=5, pady=(0, 5)) + + self._create_project_tab() + self._create_task_tab() + + self.tabview.configure(command=self._on_tab_changed) + self.tabview.set("Project") + + def _create_project_tab(self): + """创建Project标签页""" + self.project_tab = self.tabview.add("Project") + self.project_tab.grid_columnconfigure(0, weight=1) + self.project_tab.grid_rowconfigure(0, weight=1) + + # 使用ProjectPanel + self.project_panel = ProjectPanel( + self.project_tab, + self.config_manager, + self.icon_manager, + log_callback=self.window_manager.log_with_timestamp, + fg_color=COLOR_TRANSPARENT + ) + self.project_panel.grid(row=0, column=0, sticky="nsew", padx=5, pady=5) + + # 设置图标大小 + self.project_panel.set_icon_size(self.icon_size) + + # 初始化项目背景颜色 + self._update_project_background() + + def _create_task_tab(self): + """创建Task标签页""" + self.task_tab = self.tabview.add("Task") + self.task_tab.grid_columnconfigure(0, weight=1) + self.task_tab.grid_rowconfigure(0, weight=1) + + # 使用TaskPanel + self.task_panel = TaskPanel(self.task_tab, self.config_manager, fg_color=COLOR_TRANSPARENT) + self.task_panel.grid(row=0, column=0, sticky="nsew", padx=5, pady=5) + + # 初始化项目颜色 + self._update_task_colors() + + def _bind_events(self): + """绑定事件""" + self.project_combo.bind("", self._on_combo_click, add="+") + self.bind_all("", self._on_zoom, add="+") + self.bind("", self._on_window_resize) + + def _initialize_ui(self): + """初始化UI""" + self.after(100, lambda: UIHelpers.adjust_tab_button_width(self.tabview)) + self.after(150, self._configure_tab_style) + self.after(200, lambda: UIHelpers.fix_dropdown_width(self.project_combo)) + self.after(250, self._update_tab_appearance) # 延迟更新标签页外观,确保UI完全初始化 + self._update_project_list() + + # 初始化Project标签页内容 + self.after(300, self.project_panel.refresh) + + # 关闭启动画面 + if self.splash: + self.after(350, self._close_splash) + + def _close_splash(self): + """关闭启动画面""" + if self.splash: + try: + self.splash.close() + self.splash = None + except: + pass + + def _configure_tab_style(self): + """配置标签页样式""" + UIHelpers.configure_tab_transparency(self.project_tab, self.task_tab) + + def _on_combo_click(self, event): + """下拉框点击事件""" + self.after(1, lambda: UIHelpers.fix_dropdown_width(self.project_combo)) + + def _on_tab_changed(self): + """标签页切换事件""" + current_tab = self.tabview.get() + self._log(f"Switched to tab: {current_tab}", "DEBUG") + + if current_tab == "Task": + self.task_panel.refresh() + elif current_tab == "Project": + self.project_panel.refresh() + + def _on_zoom(self, event): + """处理缩放事件""" + # 检查事件是否来自主窗口 + widget = event.widget + if hasattr(widget, 'winfo_toplevel'): + if widget.winfo_toplevel() != self: + return + + # 委托给project_panel处理 + return self.project_panel.handle_zoom(event) + + def _on_window_resize(self, event): + """窗口大小改变事件""" + if event.widget == self: + if hasattr(self, '_resize_timer'): + self.after_cancel(self._resize_timer) + self._resize_timer = self.after(150, self._on_resize_complete) + + def _on_resize_complete(self): + """窗口调整完成后的处理""" + try: + self.project_panel.on_window_resize() + UIHelpers.adjust_tab_button_width(self.tabview) + except Exception as e: + self._log(f"Window adjustment handling failed: {e}", "WARNING") + + def _update_project_list(self): + """更新项目列表""" + projects = self.config_manager.get_projects() + if projects: + self.project_combo.configure(values=projects) + current_project = self.config_manager.get_current_project() + if current_project in projects: + self.project_combo.set(current_project) + else: + self.project_combo.set(projects[0]) + self.config_manager.set_current_project(projects[0]) + else: + self.project_combo.configure(values=["无项目"]) + self.project_combo.set("无项目") + + def _on_project_changed(self, choice): + """项目切换事件""" + self.config_manager.set_current_project(choice) + self._update_tab_appearance() + self.project_panel.refresh() + self.task_panel.refresh() + + def _update_tab_appearance(self): + """更新标签页外观""" + current_project = self.config_manager.get_current_project() + if not current_project: + return + + # 更新背景颜色 + self._update_project_background() + self._update_task_colors() + + # 更新标签页图标(包含高度设置) + self.project_panel.update_tab_icon(self.tabview, current_project) + + def _update_project_background(self): + """更新project panel背景颜色""" + current_project = self.config_manager.get_current_project() + if current_project: + project_color = self.config_manager.get_project_color(current_project) + if project_color: + self.project_panel.configure(fg_color=project_color) + if hasattr(self.project_panel, 'update_background_color'): + self.project_panel.update_background_color(project_color) + + def _update_task_colors(self): + """更新task panel颜色""" + current_project = self.config_manager.get_current_project() + if current_project: + project_color = self.config_manager.get_project_color(current_project) + if project_color and hasattr(self.task_panel, 'update_colors'): + self.task_panel.update_colors(project_color) + + def _open_settings(self): + """打开设置窗口""" + self.window_manager.log_with_timestamp("🔧 打开设置窗口") + settings_window = SettingsWindow(self, self.config_manager, self._on_settings_updated) + + def _on_settings_updated(self): + """设置更新后的回调""" + self.window_manager.log_with_timestamp("🔄 设置已更新,重新加载应用") + self._update_project_list() + self._update_tab_appearance() + self.project_panel.refresh() + + def log(self, message: str): + """日志记录(委托给window_manager)""" + self.window_manager.log(message) + + +def main(): + """主函数""" + app = NexusLauncher() + app.mainloop() + + +if __name__ == "__main__": + main() diff --git a/plugins/Qt.py b/plugins/Qt.py new file mode 100644 index 0000000..e024a4a --- /dev/null +++ b/plugins/Qt.py @@ -0,0 +1,2155 @@ +"""Minimal Python 2 & 3 shim around all Qt bindings + +DOCUMENTATION + Qt.py was born in the film and visual effects industry to address + the growing need for the development of software capable of running + with more than one flavour of the Qt bindings for Python. + + Supported Binding: PySide, PySide2, PySide6, PyQt4, PyQt5 + + 1. Build for one, run with all + 2. Explicit is better than implicit + 3. Support co-existence + + Default resolution order: + - PySide6 + - PySide2 + - PyQt5 + - PySide + - PyQt4 + + Usage: + >> import sys + >> from Qt import QtWidgets + >> app = QtWidgets.QApplication(sys.argv) + >> button = QtWidgets.QPushButton("Hello World") + >> button.show() + >> app.exec_() + + All members of PySide2 are mapped from other bindings, should they exist. + If no equivalent member exist, it is excluded from Qt.py and inaccessible. + The idea is to highlight members that exist across all supported binding, + and guarantee that code that runs on one binding runs on all others. + + For more details, visit https://github.com/mottosso/Qt.py + +LICENSE + + See end of file for license (MIT, BSD) information. + +""" + +import os +import sys +import types +import shutil +import importlib +import json + + +__version__ = "1.4.1" + +# Enable support for `from Qt import *` +__all__ = [] + +# Flags from environment variables +QT_VERBOSE = bool(os.getenv("QT_VERBOSE")) +QT_PREFERRED_BINDING_JSON = os.getenv("QT_PREFERRED_BINDING_JSON", "") +QT_PREFERRED_BINDING = os.getenv("QT_PREFERRED_BINDING", "") +QT_SIP_API_HINT = os.getenv("QT_SIP_API_HINT") + +# Reference to Qt.py +Qt = sys.modules[__name__] +Qt.QtCompat = types.ModuleType("QtCompat") + +try: + long +except NameError: + # Python 3 compatibility + long = int + + +"""Common members of all bindings + +This is where each member of Qt.py is explicitly defined. +It is based on a "lowest common denominator" of all bindings; +including members found in each of the 4 bindings. + +The "_common_members" dictionary is generated using the +build_membership.sh script. + +""" + +_common_members = { + "QtCore": [ + "QAbstractAnimation", + "QAbstractEventDispatcher", + "QAbstractItemModel", + "QAbstractListModel", + "QAbstractTableModel", + "QAnimationGroup", + "QBasicTimer", + "QBitArray", + "QBuffer", + "QByteArray", + "QByteArrayMatcher", + "QChildEvent", + "QCoreApplication", + "QCryptographicHash", + "QDataStream", + "QDate", + "QDateTime", + "QDir", + "QDirIterator", + "QDynamicPropertyChangeEvent", + "QEasingCurve", + "QElapsedTimer", + "QEvent", + "QEventLoop", + "QFile", + "QFileInfo", + "QFileSystemWatcher", + "QGenericArgument", + "QGenericReturnArgument", + "QIODevice", + "QLibraryInfo", + "QLine", + "QLineF", + "QLocale", + "QMargins", + "QMetaClassInfo", + "QMetaEnum", + "QMetaMethod", + "QMetaObject", + "QMetaProperty", + "QMimeData", + "QModelIndex", + "QMutex", + "QMutexLocker", + "QObject", + "QParallelAnimationGroup", + "QPauseAnimation", + "QPersistentModelIndex", + "QPluginLoader", + "QPoint", + "QPointF", + "QProcess", + "QProcessEnvironment", + "QPropertyAnimation", + "QReadLocker", + "QReadWriteLock", + "QRect", + "QRectF", + "QResource", + "QRunnable", + "QSemaphore", + "QSequentialAnimationGroup", + "QSettings", + "QSignalMapper", + "QSize", + "QSizeF", + "QSocketNotifier", + "QSysInfo", + "QSystemSemaphore", + "QT_TRANSLATE_NOOP", + "QT_TR_NOOP", + "QT_TR_NOOP_UTF8", + "QTemporaryFile", + "QTextBoundaryFinder", + "QTextStream", + "QTextStreamManipulator", + "QThread", + "QThreadPool", + "QTime", + "QTimeLine", + "QTimer", + "QTimerEvent", + "QTranslator", + "QUrl", + "QVariantAnimation", + "QWaitCondition", + "QWriteLocker", + "QXmlStreamAttribute", + "QXmlStreamAttributes", + "QXmlStreamEntityDeclaration", + "QXmlStreamEntityResolver", + "QXmlStreamNamespaceDeclaration", + "QXmlStreamNotationDeclaration", + "QXmlStreamReader", + "QXmlStreamWriter", + "Qt", + "QtMsgType", + "qAbs", + "qAddPostRoutine", + "qCritical", + "qDebug", + "qFatal", + "qFuzzyCompare", + "qIsFinite", + "qIsInf", + "qIsNaN", + "qIsNull", + "qRegisterResourceData", + "qUnregisterResourceData", + "qVersion", + "qWarning", + ], + "QtGui": [ + "QAbstractTextDocumentLayout", + "QActionEvent", + "QBitmap", + "QBrush", + "QClipboard", + "QCloseEvent", + "QColor", + "QConicalGradient", + "QContextMenuEvent", + "QCursor", + "QDesktopServices", + "QDoubleValidator", + "QDrag", + "QDragEnterEvent", + "QDragLeaveEvent", + "QDragMoveEvent", + "QDropEvent", + "QFileOpenEvent", + "QFocusEvent", + "QFont", + "QFontDatabase", + "QFontInfo", + "QFontMetrics", + "QFontMetricsF", + "QGradient", + "QHelpEvent", + "QHideEvent", + "QHoverEvent", + "QIcon", + "QIconDragEvent", + "QIconEngine", + "QImage", + "QImageIOHandler", + "QImageReader", + "QImageWriter", + "QInputEvent", + "QInputMethodEvent", + "QIntValidator", + "QKeyEvent", + "QKeySequence", + "QLinearGradient", + "QMatrix2x2", + "QMatrix2x3", + "QMatrix2x4", + "QMatrix3x2", + "QMatrix3x3", + "QMatrix3x4", + "QMatrix4x2", + "QMatrix4x3", + "QMatrix4x4", + "QMouseEvent", + "QMoveEvent", + "QMovie", + "QPaintDevice", + "QPaintEngine", + "QPaintEngineState", + "QPaintEvent", + "QPainter", + "QPainterPath", + "QPainterPathStroker", + "QPalette", + "QPen", + "QPicture", + "QPixmap", + "QPixmapCache", + "QPolygon", + "QPolygonF", + "QQuaternion", + "QRadialGradient", + "QRegion", + "QResizeEvent", + "QSessionManager", + "QShortcutEvent", + "QShowEvent", + "QStandardItem", + "QStandardItemModel", + "QStatusTipEvent", + "QSyntaxHighlighter", + "QTabletEvent", + "QTextBlock", + "QTextBlockFormat", + "QTextBlockGroup", + "QTextBlockUserData", + "QTextCharFormat", + "QTextCursor", + "QTextDocument", + "QTextDocumentFragment", + "QTextFormat", + "QTextFragment", + "QTextFrame", + "QTextFrameFormat", + "QTextImageFormat", + "QTextInlineObject", + "QTextItem", + "QTextLayout", + "QTextLength", + "QTextLine", + "QTextList", + "QTextListFormat", + "QTextObject", + "QTextObjectInterface", + "QTextOption", + "QTextTable", + "QTextTableCell", + "QTextTableCellFormat", + "QTextTableFormat", + "QTouchEvent", + "QTransform", + "QValidator", + "QVector2D", + "QVector3D", + "QVector4D", + "QWhatsThisClickedEvent", + "QWheelEvent", + "QWindowStateChangeEvent", + "qAlpha", + "qBlue", + "qGray", + "qGreen", + "qIsGray", + "qRed", + "qRgb", + "qRgba" + ], + "QtHelp": [ + "QHelpContentItem", + "QHelpContentModel", + "QHelpContentWidget", + "QHelpEngine", + "QHelpEngineCore", + "QHelpIndexModel", + "QHelpIndexWidget", + "QHelpSearchEngine", + "QHelpSearchQuery", + "QHelpSearchQueryWidget", + "QHelpSearchResultWidget" + ], + "QtNetwork": [ + "QAbstractNetworkCache", + "QAbstractSocket", + "QAuthenticator", + "QHostAddress", + "QHostInfo", + "QLocalServer", + "QLocalSocket", + "QNetworkAccessManager", + "QNetworkAddressEntry", + "QNetworkCacheMetaData", + "QNetworkCookie", + "QNetworkCookieJar", + "QNetworkDiskCache", + "QNetworkInterface", + "QNetworkProxy", + "QNetworkProxyFactory", + "QNetworkProxyQuery", + "QNetworkReply", + "QNetworkRequest", + "QSsl", + "QTcpServer", + "QTcpSocket", + "QUdpSocket" + ], + "QtPrintSupport": [ + "QAbstractPrintDialog", + "QPageSetupDialog", + "QPrintDialog", + "QPrintEngine", + "QPrintPreviewDialog", + "QPrintPreviewWidget", + "QPrinter", + "QPrinterInfo" + ], + "QtSvg": [ + "QSvgGenerator", + "QSvgRenderer" + ], + "QtTest": [ + "QTest" + ], + "QtWidgets": [ + "QAbstractButton", + "QAbstractGraphicsShapeItem", + "QAbstractItemDelegate", + "QAbstractItemView", + "QAbstractScrollArea", + "QAbstractSlider", + "QAbstractSpinBox", + "QAction", + "QApplication", + "QBoxLayout", + "QButtonGroup", + "QCalendarWidget", + "QCheckBox", + "QColorDialog", + "QColumnView", + "QComboBox", + "QCommandLinkButton", + "QCommonStyle", + "QCompleter", + "QDataWidgetMapper", + "QDateEdit", + "QDateTimeEdit", + "QDial", + "QDialog", + "QDialogButtonBox", + "QDockWidget", + "QDoubleSpinBox", + "QErrorMessage", + "QFileDialog", + "QFileIconProvider", + "QFileSystemModel", + "QFocusFrame", + "QFontComboBox", + "QFontDialog", + "QFormLayout", + "QFrame", + "QGesture", + "QGestureEvent", + "QGestureRecognizer", + "QGraphicsAnchor", + "QGraphicsAnchorLayout", + "QGraphicsBlurEffect", + "QGraphicsColorizeEffect", + "QGraphicsDropShadowEffect", + "QGraphicsEffect", + "QGraphicsEllipseItem", + "QGraphicsGridLayout", + "QGraphicsItem", + "QGraphicsItemGroup", + "QGraphicsLayout", + "QGraphicsLayoutItem", + "QGraphicsLineItem", + "QGraphicsLinearLayout", + "QGraphicsObject", + "QGraphicsOpacityEffect", + "QGraphicsPathItem", + "QGraphicsPixmapItem", + "QGraphicsPolygonItem", + "QGraphicsProxyWidget", + "QGraphicsRectItem", + "QGraphicsRotation", + "QGraphicsScale", + "QGraphicsScene", + "QGraphicsSceneContextMenuEvent", + "QGraphicsSceneDragDropEvent", + "QGraphicsSceneEvent", + "QGraphicsSceneHelpEvent", + "QGraphicsSceneHoverEvent", + "QGraphicsSceneMouseEvent", + "QGraphicsSceneMoveEvent", + "QGraphicsSceneResizeEvent", + "QGraphicsSceneWheelEvent", + "QGraphicsSimpleTextItem", + "QGraphicsTextItem", + "QGraphicsTransform", + "QGraphicsView", + "QGraphicsWidget", + "QGridLayout", + "QGroupBox", + "QHBoxLayout", + "QHeaderView", + "QInputDialog", + "QItemDelegate", + "QItemEditorCreatorBase", + "QItemEditorFactory", + "QLCDNumber", + "QLabel", + "QLayout", + "QLayoutItem", + "QLineEdit", + "QListView", + "QListWidget", + "QListWidgetItem", + "QMainWindow", + "QMdiArea", + "QMdiSubWindow", + "QMenu", + "QMenuBar", + "QMessageBox", + "QPanGesture", + "QPinchGesture", + "QPlainTextDocumentLayout", + "QPlainTextEdit", + "QProgressBar", + "QProgressDialog", + "QPushButton", + "QRadioButton", + "QRubberBand", + "QScrollArea", + "QScrollBar", + "QSizeGrip", + "QSizePolicy", + "QSlider", + "QSpacerItem", + "QSpinBox", + "QSplashScreen", + "QSplitter", + "QSplitterHandle", + "QStackedLayout", + "QStackedWidget", + "QStatusBar", + "QStyle", + "QStyleFactory", + "QStyleHintReturn", + "QStyleHintReturnMask", + "QStyleHintReturnVariant", + "QStyleOption", + "QStyleOptionButton", + "QStyleOptionComboBox", + "QStyleOptionComplex", + "QStyleOptionDockWidget", + "QStyleOptionFocusRect", + "QStyleOptionFrame", + "QStyleOptionGraphicsItem", + "QStyleOptionGroupBox", + "QStyleOptionHeader", + "QStyleOptionMenuItem", + "QStyleOptionProgressBar", + "QStyleOptionRubberBand", + "QStyleOptionSizeGrip", + "QStyleOptionSlider", + "QStyleOptionSpinBox", + "QStyleOptionTab", + "QStyleOptionTabBarBase", + "QStyleOptionTabWidgetFrame", + "QStyleOptionTitleBar", + "QStyleOptionToolBar", + "QStyleOptionToolBox", + "QStyleOptionToolButton", + "QStyleOptionViewItem", + "QStylePainter", + "QStyledItemDelegate", + "QSwipeGesture", + "QSystemTrayIcon", + "QTabBar", + "QTabWidget", + "QTableView", + "QTableWidget", + "QTableWidgetItem", + "QTableWidgetSelectionRange", + "QTapAndHoldGesture", + "QTapGesture", + "QTextBrowser", + "QTextEdit", + "QTimeEdit", + "QToolBar", + "QToolBox", + "QToolButton", + "QToolTip", + "QTreeView", + "QTreeWidget", + "QTreeWidgetItem", + "QTreeWidgetItemIterator", + "QUndoView", + "QVBoxLayout", + "QWhatsThis", + "QWidget", + "QWidgetAction", + "QWidgetItem", + "QWizard", + "QWizardPage" + ], + "QtXml": [ + "QDomAttr", + "QDomCDATASection", + "QDomCharacterData", + "QDomComment", + "QDomDocument", + "QDomDocumentFragment", + "QDomDocumentType", + "QDomElement", + "QDomEntity", + "QDomEntityReference", + "QDomImplementation", + "QDomNamedNodeMap", + "QDomNode", + "QDomNodeList", + "QDomNotation", + "QDomProcessingInstruction", + "QDomText" + ] +} + +""" Missing members + +This mapping describes members that have been deprecated +in one or more bindings and have been left out of the +_common_members mapping. + +The member can provide an extra details string to be +included in exceptions and warnings. +""" + +_missing_members = { + "QtGui": { + "QMatrix": "Deprecated in PyQt5", + }, +} + + +def _qInstallMessageHandler(handler): + """Install a message handler that works in all bindings + + Args: + handler: A function that takes 3 arguments, or None + """ + def messageOutputHandler(*args): + # In Qt4 bindings, message handlers are passed 2 arguments + # In Qt5 bindings, message handlers are passed 3 arguments + # The first argument is a QtMsgType + # The last argument is the message to be printed + # The Middle argument (if passed) is a QMessageLogContext + if len(args) == 3: + msgType, logContext, msg = args + elif len(args) == 2: + msgType, msg = args + logContext = None + else: + raise TypeError( + "handler expected 2 or 3 arguments, got {0}".format(len(args))) + + if isinstance(msg, bytes): + # In python 3, some bindings pass a bytestring, which cannot be + # used elsewhere. Decoding a python 2 or 3 bytestring object will + # consistently return a unicode object. + msg = msg.decode() + + handler(msgType, logContext, msg) + + passObject = messageOutputHandler if handler else handler + if Qt.IsPySide or Qt.IsPyQt4: + return Qt._QtCore.qInstallMsgHandler(passObject) + elif Qt.IsPySide2 or Qt.IsPyQt5 or Qt.IsPySide6: + return Qt._QtCore.qInstallMessageHandler(passObject) + + +def _getcpppointer(object): + if hasattr(Qt, "_shiboken6"): + return getattr(Qt, "_shiboken6").getCppPointer(object)[0] + elif hasattr(Qt, "_shiboken2"): + return getattr(Qt, "_shiboken2").getCppPointer(object)[0] + elif hasattr(Qt, "_shiboken"): + return getattr(Qt, "_shiboken").getCppPointer(object)[0] + elif hasattr(Qt, "_sip"): + return getattr(Qt, "_sip").unwrapinstance(object) + raise AttributeError("'module' has no attribute 'getCppPointer'") + + +def _wrapinstance(ptr, base=None): + """Enable implicit cast of pointer to most suitable class + + This behaviour is available in sip per default. + + Based on http://nathanhorne.com/pyqtpyside-wrap-instance + + Usage: + This mechanism kicks in under these circumstances. + 1. Qt.py is using PySide 1 or 2. + 2. A `base` argument is not provided. + + See :func:`QtCompat.wrapInstance()` + + Arguments: + ptr (long): Pointer to QObject in memory + base (QObject, optional): Base class to wrap with. Defaults to QObject, + which should handle anything. + + """ + + assert isinstance(ptr, long), "Argument 'ptr' must be of type " + assert (base is None) or issubclass(base, Qt.QtCore.QObject), ( + "Argument 'base' must be of type ") + + if Qt.IsPyQt4 or Qt.IsPyQt5: + func = getattr(Qt, "_sip").wrapinstance + elif Qt.IsPySide2: + func = getattr(Qt, "_shiboken2").wrapInstance + elif Qt.IsPySide6: + func = getattr(Qt, "_shiboken6").wrapInstance + elif Qt.IsPySide: + func = getattr(Qt, "_shiboken").wrapInstance + else: + raise AttributeError("'module' has no attribute 'wrapInstance'") + + if base is None: + if Qt.IsPyQt4 or Qt.IsPyQt5: + base = Qt.QtCore.QObject + else: + q_object = func(long(ptr), Qt.QtCore.QObject) + meta_object = q_object.metaObject() + + while True: + class_name = meta_object.className() + + try: + base = getattr(Qt.QtWidgets, class_name) + except AttributeError: + try: + base = getattr(Qt.QtCore, class_name) + except AttributeError: + meta_object = meta_object.superClass() + continue + + break + + return func(long(ptr), base) + + +def _isvalid(object): + """Check if the object is valid to use in Python runtime. + + Usage: + See :func:`QtCompat.isValid()` + + Arguments: + object (QObject): QObject to check the validity of. + + """ + if hasattr(Qt, "_shiboken6"): + return getattr(Qt, "_shiboken6").isValid(object) + + elif hasattr(Qt, "_shiboken2"): + return getattr(Qt, "_shiboken2").isValid(object) + + elif hasattr(Qt, "_shiboken"): + return getattr(Qt, "_shiboken").isValid(object) + + elif hasattr(Qt, "_sip"): + return not getattr(Qt, "_sip").isdeleted(object) + + else: + raise AttributeError("'module' has no attribute isValid") + + +def _translate(context, sourceText, *args): + # In Qt4 bindings, translate can be passed 2 or 3 arguments + # In Qt5 bindings, translate can be passed 2 arguments + # The first argument is disambiguation[str] + # The last argument is n[int] + # The middle argument can be encoding[QtCore.QCoreApplication.Encoding] + try: + app = Qt.QtCore.QCoreApplication + except AttributeError: + raise NotImplementedError( + "Missing QCoreApplication implementation for {}".format( + Qt.__binding__ + ) + ) + + def get_arg(index): + try: + return args[index] + except IndexError: + pass + + n = -1 + encoding = None + + if len(args) == 3: + disambiguation, encoding, n = args + else: + disambiguation = get_arg(0) + n_or_encoding = get_arg(1) + + if isinstance(n_or_encoding, int): + n = n_or_encoding + else: + encoding = n_or_encoding + + if Qt.__binding__ in ("PySide2", "PySide6","PyQt5"): + sanitized_args = [context, sourceText, disambiguation, n] + else: + sanitized_args = [ + context, + sourceText, + disambiguation, + encoding or app.CodecForTr, + n, + ] + + return app.translate(*sanitized_args) + + +def _loadUi(uifile, baseinstance=None): + """Dynamically load a user interface from the given `uifile` + + This function calls `uic.loadUi` if using PyQt bindings, + else it implements a comparable binding for PySide. + + Documentation: + http://pyqt.sourceforge.net/Docs/PyQt5/designer.html#PyQt5.uic.loadUi + + Arguments: + uifile (str): Absolute path to Qt Designer file. + baseinstance (QWidget): Instantiated QWidget or subclass thereof + + Return: + baseinstance if `baseinstance` is not `None`. Otherwise + return the newly created instance of the user interface. + + """ + if hasattr(Qt, "_uic"): + return Qt._uic.loadUi(uifile, baseinstance) + + elif hasattr(Qt, "_QtUiTools"): + # Implement `PyQt5.uic.loadUi` for PySide(2) + + class _UiLoader(Qt._QtUiTools.QUiLoader): + """Create the user interface in a base instance. + + Unlike `Qt._QtUiTools.QUiLoader` itself this class does not + create a new instance of the top-level widget, but creates the user + interface in an existing instance of the top-level class if needed. + + This mimics the behaviour of `PyQt5.uic.loadUi`. + + """ + + def __init__(self, baseinstance): + super(_UiLoader, self).__init__(baseinstance) + self.baseinstance = baseinstance + self.custom_widgets = {} + + def _loadCustomWidgets(self, etree): + """ + Workaround to pyside-77 bug. + + From QUiLoader doc we should use registerCustomWidget method. + But this causes a segfault on some platforms. + + Instead we fetch from customwidgets DOM node the python class + objects. Then we can directly use them in createWidget method. + """ + + def headerToModule(header): + """ + Translate a header file to python module path + foo/bar.h => foo.bar + """ + # Remove header extension + module = os.path.splitext(header)[0] + + # Replace os separator by python module separator + return module.replace("/", ".").replace("\\", ".") + + custom_widgets = etree.find("customwidgets") + + if custom_widgets is None: + return + + for custom_widget in custom_widgets: + class_name = custom_widget.find("class").text + header = custom_widget.find("header").text + + try: + # try to import the module using the header as defined by the user + module = importlib.import_module(header) + except ImportError: + # try again, but use the customized conversion of a path to a module + module = importlib.import_module(headerToModule(header)) + + self.custom_widgets[class_name] = getattr(module, + class_name) + + def load(self, uifile, *args, **kwargs): + from xml.etree.ElementTree import ElementTree + + # For whatever reason, if this doesn't happen then + # reading an invalid or non-existing .ui file throws + # a RuntimeError. + etree = ElementTree() + etree.parse(uifile) + self._loadCustomWidgets(etree) + + widget = Qt._QtUiTools.QUiLoader.load( + self, uifile, *args, **kwargs) + + # Workaround for PySide 1.0.9, see issue #208 + widget.parentWidget() + + return widget + + def createWidget(self, class_name, parent=None, name=""): + """Called for each widget defined in ui file + + Overridden here to populate `baseinstance` instead. + + """ + + if parent is None and self.baseinstance: + # Supposed to create the top-level widget, + # return the base instance instead + return self.baseinstance + + # For some reason, Line is not in the list of available + # widgets, but works fine, so we have to special case it here. + if class_name in self.availableWidgets() + ["Line"]: + # Create a new widget for child widgets + widget = Qt._QtUiTools.QUiLoader.createWidget(self, + class_name, + parent, + name) + elif class_name in self.custom_widgets: + widget = self.custom_widgets[class_name](parent=parent) + else: + raise Exception("Custom widget '%s' not supported" + % class_name) + + if self.baseinstance: + # Set an attribute for the new child widget on the base + # instance, just like PyQt5.uic.loadUi does. + setattr(self.baseinstance, name, widget) + + return widget + + widget = _UiLoader(baseinstance).load(uifile) + Qt.QtCore.QMetaObject.connectSlotsByName(widget) + + return widget + + else: + raise NotImplementedError("No implementation available for loadUi") + + +"""Misplaced members + +These members from the original submodule are misplaced relative PySide2 + +NOTE: For bindings where a member is not replaced, they still + need to be added such that they are added to Qt.py + +""" +_misplaced_members = { + "PySide6": { + "QtGui.QUndoCommand": "QtWidgets.QUndoCommand", + "QtGui.QUndoGroup": "QtWidgets.QUndoGroup", + "QtGui.QUndoStack": "QtWidgets.QUndoStack", + "QtGui.QActionGroup": "QtWidgets.QActionGroup", + "QtCore.QStringListModel": "QtCore.QStringListModel", + "QtCore.Property": "QtCore.Property", + "QtCore.Signal": "QtCore.Signal", + "QtCore.Slot": "QtCore.Slot", + "QtCore.QAbstractProxyModel": "QtCore.QAbstractProxyModel", + "QtCore.QSortFilterProxyModel": "QtCore.QSortFilterProxyModel", + "QtCore.QItemSelection": "QtCore.QItemSelection", + "QtCore.QItemSelectionModel": "QtCore.QItemSelectionModel", + "QtCore.QItemSelectionRange": "QtCore.QItemSelectionRange", + "QtCore.QRegularExpression": "QtCore.QRegExp", + "QtStateMachine.QStateMachine": "QtCore.QStateMachine", + "QtStateMachine.QState": "QtCore.QState", + "QtGui.QRegularExpressionValidator": "QtGui.QRegExpValidator", + "QtGui.QShortcut": "QtWidgets.QShortcut", + "QtGui.QAction": "QtWidgets.QAction", + "QtUiTools.QUiLoader": ["QtCompat.loadUi", _loadUi], + "shiboken6.wrapInstance": ["QtCompat.wrapInstance", _wrapinstance], + "shiboken6.getCppPointer": ["QtCompat.getCppPointer", _getcpppointer], + "shiboken6.isValid": ["QtCompat.isValid", _isvalid], + "QtWidgets.qApp": "QtWidgets.QApplication.instance()", + "QtCore.QCoreApplication.translate": [ + "QtCompat.translate", _translate + ], + "QtWidgets.QApplication.translate": [ + "QtCompat.translate", _translate + ], + "QtCore.qInstallMessageHandler": [ + "QtCompat.qInstallMessageHandler", _qInstallMessageHandler + ], + "QtWidgets.QStyleOptionViewItem": "QtCompat.QStyleOptionViewItemV4", + }, + "PySide2": { + "QtWidgets.QUndoCommand": "QtWidgets.QUndoCommand", + "QtWidgets.QUndoGroup": "QtWidgets.QUndoGroup", + "QtWidgets.QUndoStack": "QtWidgets.QUndoStack", + "QtWidgets.QActionGroup": "QtWidgets.QActionGroup", + "QtCore.QStringListModel": "QtCore.QStringListModel", + + # Older versions of PySide2 still left this in QtGui, this accounts for those too + "QtGui.QStringListModel": "QtCore.QStringListModel", + + "QtCore.Property": "QtCore.Property", + "QtCore.Signal": "QtCore.Signal", + "QtCore.Slot": "QtCore.Slot", + "QtCore.QRegExp": "QtCore.QRegExp", + "QtWidgets.QShortcut": "QtWidgets.QShortcut", + "QtGui.QRegExpValidator": "QtGui.QRegExpValidator", + "QtCore.QAbstractProxyModel": "QtCore.QAbstractProxyModel", + "QtCore.QSortFilterProxyModel": "QtCore.QSortFilterProxyModel", + "QtCore.QItemSelection": "QtCore.QItemSelection", + "QtCore.QItemSelectionModel": "QtCore.QItemSelectionModel", + "QtCore.QItemSelectionRange": "QtCore.QItemSelectionRange", + "QtUiTools.QUiLoader": ["QtCompat.loadUi", _loadUi], + "shiboken2.wrapInstance": ["QtCompat.wrapInstance", _wrapinstance], + "shiboken2.getCppPointer": ["QtCompat.getCppPointer", _getcpppointer], + "shiboken2.isValid": ["QtCompat.isValid", _isvalid], + "QtWidgets.qApp": "QtWidgets.QApplication.instance()", + "QtCore.QCoreApplication.translate": [ + "QtCompat.translate", _translate + ], + "QtWidgets.QApplication.translate": [ + "QtCompat.translate", _translate + ], + "QtCore.qInstallMessageHandler": [ + "QtCompat.qInstallMessageHandler", _qInstallMessageHandler + ], + "QtWidgets.QStyleOptionViewItem": "QtCompat.QStyleOptionViewItemV4", + }, + "PyQt5": { + "QtWidgets.QUndoCommand": "QtWidgets.QUndoCommand", + "QtWidgets.QUndoGroup": "QtWidgets.QUndoGroup", + "QtWidgets.QUndoStack": "QtWidgets.QUndoStack", + "QtWidgets.QActionGroup": "QtWidgets.QActionGroup", + "QtCore.pyqtProperty": "QtCore.Property", + "QtCore.pyqtSignal": "QtCore.Signal", + "QtCore.pyqtSlot": "QtCore.Slot", + "QtCore.QAbstractProxyModel": "QtCore.QAbstractProxyModel", + "QtCore.QSortFilterProxyModel": "QtCore.QSortFilterProxyModel", + "QtCore.QStringListModel": "QtCore.QStringListModel", + "QtCore.QItemSelection": "QtCore.QItemSelection", + "QtCore.QItemSelectionModel": "QtCore.QItemSelectionModel", + "QtCore.QItemSelectionRange": "QtCore.QItemSelectionRange", + "uic.loadUi": ["QtCompat.loadUi", _loadUi], + "sip.wrapinstance": ["QtCompat.wrapInstance", _wrapinstance], + "sip.unwrapinstance": ["QtCompat.getCppPointer", _getcpppointer], + "sip.isdeleted": ["QtCompat.isValid", _isvalid], + "QtWidgets.qApp": "QtWidgets.QApplication.instance()", + "QtGui.QRegExpValidator": "QtGui.QRegExpValidator", + "QtCore.QRegExp": "QtCore.QRegExp", + "QtCore.QCoreApplication.translate": [ + "QtCompat.translate", _translate + ], + "QtWidgets.QApplication.translate": [ + "QtCompat.translate", _translate + ], + "QtCore.qInstallMessageHandler": [ + "QtCompat.qInstallMessageHandler", _qInstallMessageHandler + ], + "QtWidgets.QShortcut": "QtWidgets.QShortcut", + "QtWidgets.QStyleOptionViewItem": "QtCompat.QStyleOptionViewItemV4", + }, + "PySide": { + "QtGui.QUndoCommand": "QtWidgets.QUndoCommand", + "QtGui.QUndoGroup": "QtWidgets.QUndoGroup", + "QtGui.QUndoStack": "QtWidgets.QUndoStack", + "QtGui.QActionGroup": "QtWidgets.QActionGroup", + "QtCore.Property": "QtCore.Property", + "QtCore.Signal": "QtCore.Signal", + "QtCore.Slot": "QtCore.Slot", + "QtGui.QAbstractProxyModel": "QtCore.QAbstractProxyModel", + "QtGui.QSortFilterProxyModel": "QtCore.QSortFilterProxyModel", + "QtGui.QStringListModel": "QtCore.QStringListModel", + "QtGui.QItemSelection": "QtCore.QItemSelection", + "QtGui.QItemSelectionModel": "QtCore.QItemSelectionModel", + "QtGui.QItemSelectionRange": "QtCore.QItemSelectionRange", + "QtGui.QAbstractPrintDialog": "QtPrintSupport.QAbstractPrintDialog", + "QtGui.QRegExpValidator": "QtGui.QRegExpValidator", + "QtGui.QPageSetupDialog": "QtPrintSupport.QPageSetupDialog", + "QtGui.QPrintDialog": "QtPrintSupport.QPrintDialog", + "QtGui.QPrintEngine": "QtPrintSupport.QPrintEngine", + "QtGui.QPrintPreviewDialog": "QtPrintSupport.QPrintPreviewDialog", + "QtGui.QPrintPreviewWidget": "QtPrintSupport.QPrintPreviewWidget", + "QtGui.QPrinter": "QtPrintSupport.QPrinter", + "QtWidgets.QShortcut": "QtWidgets.QShortcut", + "QtGui.QPrinterInfo": "QtPrintSupport.QPrinterInfo", + "QtUiTools.QUiLoader": ["QtCompat.loadUi", _loadUi], + "shiboken.wrapInstance": ["QtCompat.wrapInstance", _wrapinstance], + "shiboken.unwrapInstance": ["QtCompat.getCppPointer", _getcpppointer], + "shiboken.isValid": ["QtCompat.isValid", _isvalid], + "QtGui.qApp": "QtWidgets.QApplication.instance()", + "QtCore.QRegExp": "QtCore.QRegExp", + "QtCore.QCoreApplication.translate": [ + "QtCompat.translate", _translate + ], + "QtGui.QApplication.translate": [ + "QtCompat.translate", _translate + ], + "QtCore.qInstallMsgHandler": [ + "QtCompat.qInstallMessageHandler", _qInstallMessageHandler + ], + "QtGui.QStyleOptionViewItemV4": "QtCompat.QStyleOptionViewItemV4", + }, + "PyQt4": { + "QtGui.QUndoCommand": "QtWidgets.QUndoCommand", + "QtGui.QUndoGroup": "QtWidgets.QUndoGroup", + "QtGui.QUndoStack": "QtWidgets.QUndoStack", + "QtGui.QActionGroup": "QtWidgets.QActionGroup", + "QtGui.QAbstractProxyModel": "QtCore.QAbstractProxyModel", + "QtGui.QSortFilterProxyModel": "QtCore.QSortFilterProxyModel", + "QtGui.QItemSelection": "QtCore.QItemSelection", + "QtGui.QStringListModel": "QtCore.QStringListModel", + "QtGui.QItemSelectionModel": "QtCore.QItemSelectionModel", + "QtCore.pyqtProperty": "QtCore.Property", + "QtCore.pyqtSignal": "QtCore.Signal", + "QtCore.pyqtSlot": "QtCore.Slot", + "QtGui.QItemSelectionRange": "QtCore.QItemSelectionRange", + "QtGui.QAbstractPrintDialog": "QtPrintSupport.QAbstractPrintDialog", + "QtGui.QRegExpValidator": "QtGui.QRegExpValidator", + "QtGui.QPageSetupDialog": "QtPrintSupport.QPageSetupDialog", + "QtGui.QPrintDialog": "QtPrintSupport.QPrintDialog", + "QtGui.QPrintEngine": "QtPrintSupport.QPrintEngine", + "QtWidgets.QShortcut": "QtWidgets.QShortcut", + "QtGui.QPrintPreviewDialog": "QtPrintSupport.QPrintPreviewDialog", + "QtGui.QPrintPreviewWidget": "QtPrintSupport.QPrintPreviewWidget", + "QtGui.QPrinter": "QtPrintSupport.QPrinter", + "QtGui.QPrinterInfo": "QtPrintSupport.QPrinterInfo", + "uic.loadUi": ["QtCompat.loadUi", _loadUi], + "sip.wrapinstance": ["QtCompat.wrapInstance", _wrapinstance], + "sip.unwrapinstance": ["QtCompat.getCppPointer", _getcpppointer], + "sip.isdeleted": ["QtCompat.isValid", _isvalid], + "QtCore.QString": "str", + "QtGui.qApp": "QtWidgets.QApplication.instance()", + "QtCore.QRegExp": "QtCore.QRegExp", + "QtCore.QCoreApplication.translate": [ + "QtCompat.translate", _translate + ], + "QtGui.QApplication.translate": [ + "QtCompat.translate", _translate + ], + "QtCore.qInstallMsgHandler": [ + "QtCompat.qInstallMessageHandler", _qInstallMessageHandler + ], + "QtGui.QStyleOptionViewItemV4": "QtCompat.QStyleOptionViewItemV4", + } +} + +""" Compatibility Members + +This dictionary is used to build Qt.QtCompat objects that provide a consistent +interface for obsolete members, and differences in binding return values. + +{ + "binding": { + "classname": { + "targetname": "binding_namespace", + } + } +} +""" +_compatibility_members = { + "PySide6": { + "QWidget": { + "grab": "QtWidgets.QWidget.grab", + }, + "QHeaderView": { + "sectionsClickable": "QtWidgets.QHeaderView.sectionsClickable", + "setSectionsClickable": + "QtWidgets.QHeaderView.setSectionsClickable", + "sectionResizeMode": "QtWidgets.QHeaderView.sectionResizeMode", + "setSectionResizeMode": + "QtWidgets.QHeaderView.setSectionResizeMode", + "sectionsMovable": "QtWidgets.QHeaderView.sectionsMovable", + "setSectionsMovable": "QtWidgets.QHeaderView.setSectionsMovable", + }, + "QFileDialog": { + "getOpenFileName": "QtWidgets.QFileDialog.getOpenFileName", + "getOpenFileNames": "QtWidgets.QFileDialog.getOpenFileNames", + "getSaveFileName": "QtWidgets.QFileDialog.getSaveFileName", + }, + "QFont":{ + "setWeight": "QtGui.QFont.setWeight", + }, + "Qt": { + "MidButton": "QtCore.Qt.MiddleButton", + }, + }, + "PySide2": { + "QWidget": { + "grab": "QtWidgets.QWidget.grab", + }, + "QHeaderView": { + "sectionsClickable": "QtWidgets.QHeaderView.sectionsClickable", + "setSectionsClickable": + "QtWidgets.QHeaderView.setSectionsClickable", + "sectionResizeMode": "QtWidgets.QHeaderView.sectionResizeMode", + "setSectionResizeMode": + "QtWidgets.QHeaderView.setSectionResizeMode", + "sectionsMovable": "QtWidgets.QHeaderView.sectionsMovable", + "setSectionsMovable": "QtWidgets.QHeaderView.setSectionsMovable", + }, + "QFileDialog": { + "getOpenFileName": "QtWidgets.QFileDialog.getOpenFileName", + "getOpenFileNames": "QtWidgets.QFileDialog.getOpenFileNames", + "getSaveFileName": "QtWidgets.QFileDialog.getSaveFileName", + }, + "QFont":{ + "setWeight": "QtGui.QFont.setWeight", + }, + "Qt": { + "MidButton": "QtCore.Qt.MiddleButton", + }, + }, + "PyQt5": { + "QWidget": { + "grab": "QtWidgets.QWidget.grab", + }, + "QHeaderView": { + "sectionsClickable": "QtWidgets.QHeaderView.sectionsClickable", + "setSectionsClickable": + "QtWidgets.QHeaderView.setSectionsClickable", + "sectionResizeMode": "QtWidgets.QHeaderView.sectionResizeMode", + "setSectionResizeMode": + "QtWidgets.QHeaderView.setSectionResizeMode", + "sectionsMovable": "QtWidgets.QHeaderView.sectionsMovable", + "setSectionsMovable": "QtWidgets.QHeaderView.setSectionsMovable", + }, + "QFileDialog": { + "getOpenFileName": "QtWidgets.QFileDialog.getOpenFileName", + "getOpenFileNames": "QtWidgets.QFileDialog.getOpenFileNames", + "getSaveFileName": "QtWidgets.QFileDialog.getSaveFileName", + }, + "QFont":{ + "setWeight": "QtGui.QFont.setWeight", + }, + "Qt": { + "MidButton": "QtCore.Qt.MiddleButton", + }, + }, + "PySide": { + "QWidget": { + "grab": "QtWidgets.QPixmap.grabWidget", + }, + "QHeaderView": { + "sectionsClickable": "QtWidgets.QHeaderView.isClickable", + "setSectionsClickable": "QtWidgets.QHeaderView.setClickable", + "sectionResizeMode": "QtWidgets.QHeaderView.resizeMode", + "setSectionResizeMode": "QtWidgets.QHeaderView.setResizeMode", + "sectionsMovable": "QtWidgets.QHeaderView.isMovable", + "setSectionsMovable": "QtWidgets.QHeaderView.setMovable", + }, + "QFileDialog": { + "getOpenFileName": "QtWidgets.QFileDialog.getOpenFileName", + "getOpenFileNames": "QtWidgets.QFileDialog.getOpenFileNames", + "getSaveFileName": "QtWidgets.QFileDialog.getSaveFileName", + }, + "QFont":{ + "setWeight": "QtGui.QFont.setWeight", + }, + "Qt": { + "MidButton": "QtCore.Qt.MiddleButton", + }, + }, + "PyQt4": { + "QWidget": { + "grab": "QtWidgets.QPixmap.grabWidget", + }, + "QHeaderView": { + "sectionsClickable": "QtWidgets.QHeaderView.isClickable", + "setSectionsClickable": "QtWidgets.QHeaderView.setClickable", + "sectionResizeMode": "QtWidgets.QHeaderView.resizeMode", + "setSectionResizeMode": "QtWidgets.QHeaderView.setResizeMode", + "sectionsMovable": "QtWidgets.QHeaderView.isMovable", + "setSectionsMovable": "QtWidgets.QHeaderView.setMovable", + }, + "QFileDialog": { + "getOpenFileName": "QtWidgets.QFileDialog.getOpenFileName", + "getOpenFileNames": "QtWidgets.QFileDialog.getOpenFileNames", + "getSaveFileName": "QtWidgets.QFileDialog.getSaveFileName", + }, + "QFont":{ + "setWeight": "QtGui.QFont.setWeight", + }, + "Qt": { + "MidButton": "QtCore.Qt.MiddleButton", + }, + }, +} + + +def _apply_site_config(): + try: + import QtSiteConfig + except ImportError: + # If no QtSiteConfig module found, no modifications + # to _common_members are needed. + pass + else: + # Provide the ability to modify the dicts used to build Qt.py + if hasattr(QtSiteConfig, 'update_members'): + QtSiteConfig.update_members(_common_members) + + if hasattr(QtSiteConfig, 'update_misplaced_members'): + QtSiteConfig.update_misplaced_members(members=_misplaced_members) + + if hasattr(QtSiteConfig, 'update_compatibility_members'): + QtSiteConfig.update_compatibility_members( + members=_compatibility_members) + + +def _new_module(name): + return types.ModuleType(__name__ + "." + name) + + +def _import_sub_module(module, name): + """import_sub_module will mimic the function of importlib.import_module""" + module = __import__(module.__name__ + "." + name) + for level in name.split("."): + module = getattr(module, level) + return module + + +def _setup(module, extras): + """Install common submodules""" + + Qt.__binding__ = module.__name__ + + def _warn_import_error(exc, module): + msg = str(exc) + if "No module named" in msg: + return + _warn("ImportError(%s): %s" % (module, msg)) + + for name in list(_common_members) + extras: + try: + submodule = _import_sub_module( + module, name) + except ImportError as e: + try: + # For extra modules like sip and shiboken that may not be + # children of the binding. + submodule = __import__(name) + except ImportError as e2: + _warn_import_error(e, name) + _warn_import_error(e2, name) + continue + + setattr(Qt, "_" + name, submodule) + + if name not in extras: + # Store reference to original binding, + # but don't store speciality modules + # such as uic or QtUiTools + setattr(Qt, name, _new_module(name)) + + +def _reassign_misplaced_members(binding): + """Apply misplaced members from `binding` to Qt.py + + Arguments: + binding (dict): Misplaced members + + """ + + + for src, dst in _misplaced_members[binding].items(): + dst_value = None + + src_parts = src.split(".") + src_module = src_parts[0] + src_member = None + if len(src_parts) > 1: + src_member = src_parts[1:] + + if isinstance(dst, (list, tuple)): + dst, dst_value = dst + + dst_parts = dst.split(".") + dst_module = dst_parts[0] + dst_member = None + if len(dst_parts) > 1: + dst_member = dst_parts[1] + + + # Get the member we want to store in the namesapce. + if not dst_value: + try: + _part = getattr(Qt, "_" + src_module) + while src_member: + member = src_member.pop(0) + _part = getattr(_part, member) + dst_value = _part + except AttributeError: + # If the member we want to store in the namespace does not + # exist, there is no need to continue. This can happen if a + # request was made to rename a member that didn't exist, for + # example if QtWidgets isn't available on the target platform. + _log("Misplaced member has no source: {0}".format(src)) + continue + + try: + src_object = getattr(Qt, dst_module) + except AttributeError: + if dst_module not in _common_members: + # Only create the Qt parent module if its listed in + # _common_members. Without this check, if you remove QtCore + # from _common_members, the default _misplaced_members will add + # Qt.QtCore so it can add Signal, Slot, etc. + msg = 'Not creating missing member module "{m}" for "{c}"' + _log(msg.format(m=dst_module, c=dst_member)) + continue + # If the dst is valid but the Qt parent module does not exist + # then go ahead and create a new module to contain the member. + setattr(Qt, dst_module, _new_module(dst_module)) + src_object = getattr(Qt, dst_module) + # Enable direct import of the new module + sys.modules[__name__ + "." + dst_module] = src_object + + if not dst_value: + dst_value = getattr(Qt, "_" + src_module) + if src_member: + dst_value = getattr(dst_value, src_member) + + setattr( + src_object, + dst_member or dst_module, + dst_value + ) + + +def _build_compatibility_members(binding, decorators=None): + """Apply `binding` to QtCompat + + Arguments: + binding (str): Top level binding in _compatibility_members. + decorators (dict, optional): Provides the ability to decorate the + original Qt methods when needed by a binding. This can be used + to change the returned value to a standard value. The key should + be the classname, the value is a dict where the keys are the + target method names, and the values are the decorator functions. + + """ + + decorators = decorators or dict() + + # Allow optional site-level customization of the compatibility members. + # This method does not need to be implemented in QtSiteConfig. + try: + import QtSiteConfig + except ImportError: + pass + else: + if hasattr(QtSiteConfig, 'update_compatibility_decorators'): + QtSiteConfig.update_compatibility_decorators(binding, decorators) + + _QtCompat = type("QtCompat", (object,), {}) + + for classname, bindings in _compatibility_members[binding].items(): + attrs = {} + for target, binding in bindings.items(): + namespaces = binding.split('.') + try: + src_object = getattr(Qt, "_" + namespaces[0]) + except AttributeError as e: + _log("QtCompat: AttributeError: %s" % e) + # Skip reassignment of non-existing members. + # This can happen if a request was made to + # rename a member that didn't exist, for example + # if QtWidgets isn't available on the target platform. + continue + + # Walk down any remaining namespace getting the object assuming + # that if the first namespace exists the rest will exist. + for namespace in namespaces[1:]: + src_object = getattr(src_object, namespace) + + # decorate the Qt method if a decorator was provided. + if target in decorators.get(classname, []): + # staticmethod must be called on the decorated method to + # prevent a TypeError being raised when the decorated method + # is called. + src_object = staticmethod( + decorators[classname][target](src_object)) + + attrs[target] = src_object + + # Create the QtCompat class and install it into the namespace + compat_class = type(classname, (_QtCompat,), attrs) + setattr(Qt.QtCompat, classname, compat_class) + + +def _pyside6(): + """Initialise PySide6 + + These functions serve to test the existence of a binding + along with set it up in such a way that it aligns with + the final step; adding members from the original binding + to Qt.py + + """ + + import PySide6 as module + extras = ["QtUiTools"] + try: + import shiboken6 + extras.append("shiboken6") + except ImportError as e: + print("ImportError: %s" % e) + + _setup(module, extras) + Qt.__binding_version__ = module.__version__ + + if hasattr(Qt, "_shiboken6"): + Qt.QtCompat.wrapInstance = _wrapinstance + Qt.QtCompat.getCppPointer = _getcpppointer + Qt.QtCompat.delete = shiboken6.delete + + if hasattr(Qt, "_QtUiTools"): + Qt.QtCompat.loadUi = _loadUi + + if hasattr(Qt, "_QtCore"): + Qt.__qt_version__ = Qt._QtCore.qVersion() + Qt.QtCompat.dataChanged = ( + lambda self, topleft, bottomright, roles=None: + self.dataChanged.emit(topleft, bottomright, roles or []) + ) + + if hasattr(Qt, "_QtWidgets"): + Qt.QtCompat.setSectionResizeMode = \ + Qt._QtWidgets.QHeaderView.setSectionResizeMode + + def setWeight(func): + def wrapper(self, weight): + weight = { + 100: Qt._QtGui.QFont.Thin, + 200: Qt._QtGui.QFont.ExtraLight, + 300: Qt._QtGui.QFont.Light, + 400: Qt._QtGui.QFont.Normal, + 500: Qt._QtGui.QFont.Medium, + 600: Qt._QtGui.QFont.DemiBold, + 700: Qt._QtGui.QFont.Bold, + 800: Qt._QtGui.QFont.ExtraBold, + 900: Qt._QtGui.QFont.Black, + }.get(weight, Qt._QtGui.QFont.Normal) + + return func(self, weight) + + wrapper.__doc__ = func.__doc__ + wrapper.__name__ = func.__name__ + + return wrapper + + + decorators = { + "QFont": { + "setWeight": setWeight, + } + } + + _reassign_misplaced_members("PySide6") + _build_compatibility_members("PySide6", decorators) + + +def _pyside2(): + """Initialise PySide2 + + These functions serve to test the existence of a binding + along with set it up in such a way that it aligns with + the final step; adding members from the original binding + to Qt.py + + """ + + import PySide2 as module + extras = ["QtUiTools"] + try: + try: + # Before merge of PySide and shiboken + import shiboken2 + except ImportError: + # After merge of PySide and shiboken, May 2017 + from PySide2 import shiboken2 + extras.append("shiboken2") + except ImportError: + pass + + _setup(module, extras) + Qt.__binding_version__ = module.__version__ + + if hasattr(Qt, "_shiboken2"): + Qt.QtCompat.wrapInstance = _wrapinstance + Qt.QtCompat.getCppPointer = _getcpppointer + Qt.QtCompat.delete = shiboken2.delete + + if hasattr(Qt, "_QtUiTools"): + Qt.QtCompat.loadUi = _loadUi + + if hasattr(Qt, "_QtCore"): + Qt.__qt_version__ = Qt._QtCore.qVersion() + Qt.QtCompat.dataChanged = ( + lambda self, topleft, bottomright, roles=None: + self.dataChanged.emit(topleft, bottomright, roles or []) + ) + + if hasattr(Qt, "_QtWidgets"): + Qt.QtCompat.setSectionResizeMode = \ + Qt._QtWidgets.QHeaderView.setSectionResizeMode + + _reassign_misplaced_members("PySide2") + _build_compatibility_members("PySide2") + + +def _pyside(): + """Initialise PySide""" + + import PySide as module + extras = ["QtUiTools"] + try: + try: + # Before merge of PySide and shiboken + import shiboken + except ImportError: + # After merge of PySide and shiboken, May 2017 + from PySide import shiboken + extras.append("shiboken") + except ImportError: + pass + + _setup(module, extras) + Qt.__binding_version__ = module.__version__ + + if hasattr(Qt, "_shiboken"): + Qt.QtCompat.wrapInstance = _wrapinstance + Qt.QtCompat.getCppPointer = _getcpppointer + Qt.QtCompat.delete = shiboken.delete + + if hasattr(Qt, "_QtUiTools"): + Qt.QtCompat.loadUi = _loadUi + + if hasattr(Qt, "_QtGui"): + setattr(Qt, "QtWidgets", _new_module("QtWidgets")) + setattr(Qt, "_QtWidgets", Qt._QtGui) + if hasattr(Qt._QtGui, "QX11Info"): + setattr(Qt, "QtX11Extras", _new_module("QtX11Extras")) + Qt.QtX11Extras.QX11Info = Qt._QtGui.QX11Info + + Qt.QtCompat.setSectionResizeMode = Qt._QtGui.QHeaderView.setResizeMode + + if hasattr(Qt, "_QtCore"): + Qt.__qt_version__ = Qt._QtCore.qVersion() + Qt.QtCompat.dataChanged = ( + lambda self, topleft, bottomright, roles=None: + self.dataChanged.emit(topleft, bottomright) + ) + + _reassign_misplaced_members("PySide") + _build_compatibility_members("PySide") + + +def _pyqt5(): + """Initialise PyQt5""" + + import PyQt5 as module + extras = ["uic"] + + try: + # Relevant to PyQt5 5.11 and above + from PyQt5 import sip + extras += ["sip"] + except ImportError: + + try: + import sip + extras += ["sip"] + except ImportError: + sip = None + + _setup(module, extras) + if hasattr(Qt, "_sip"): + Qt.QtCompat.wrapInstance = _wrapinstance + Qt.QtCompat.getCppPointer = _getcpppointer + Qt.QtCompat.delete = sip.delete + + if hasattr(Qt, "_uic"): + Qt.QtCompat.loadUi = _loadUi + + if hasattr(Qt, "_QtCore"): + Qt.__binding_version__ = Qt._QtCore.PYQT_VERSION_STR + Qt.__qt_version__ = Qt._QtCore.QT_VERSION_STR + Qt.QtCompat.dataChanged = ( + lambda self, topleft, bottomright, roles=None: + self.dataChanged.emit(topleft, bottomright, roles or []) + ) + + if hasattr(Qt, "_QtWidgets"): + Qt.QtCompat.setSectionResizeMode = \ + Qt._QtWidgets.QHeaderView.setSectionResizeMode + + _reassign_misplaced_members("PyQt5") + _build_compatibility_members('PyQt5') + + +def _pyqt4(): + """Initialise PyQt4""" + + import sip + + # Validation of envivornment variable. Prevents an error if + # the variable is invalid since it's just a hint. + try: + hint = int(QT_SIP_API_HINT) + except TypeError: + hint = None # Variable was None, i.e. not set. + except ValueError: + raise ImportError("QT_SIP_API_HINT=%s must be a 1 or 2") + + for api in ("QString", + "QVariant", + "QDate", + "QDateTime", + "QTextStream", + "QTime", + "QUrl"): + try: + sip.setapi(api, hint or 2) + except AttributeError: + raise ImportError("PyQt4 < 4.6 isn't supported by Qt.py") + except ValueError: + actual = sip.getapi(api) + if not hint: + raise ImportError("API version already set to %d" % actual) + else: + # Having provided a hint indicates a soft constraint, one + # that doesn't throw an exception. + sys.stderr.write( + "Warning: API '%s' has already been set to %d.\n" + % (api, actual) + ) + + import PyQt4 as module + extras = ["uic"] + try: + import sip + extras.append(sip.__name__) + except ImportError: + sip = None + + _setup(module, extras) + if hasattr(Qt, "_sip"): + Qt.QtCompat.wrapInstance = _wrapinstance + Qt.QtCompat.getCppPointer = _getcpppointer + Qt.QtCompat.delete = sip.delete + + if hasattr(Qt, "_uic"): + Qt.QtCompat.loadUi = _loadUi + + if hasattr(Qt, "_QtGui"): + setattr(Qt, "QtWidgets", _new_module("QtWidgets")) + setattr(Qt, "_QtWidgets", Qt._QtGui) + if hasattr(Qt._QtGui, "QX11Info"): + setattr(Qt, "QtX11Extras", _new_module("QtX11Extras")) + Qt.QtX11Extras.QX11Info = Qt._QtGui.QX11Info + + Qt.QtCompat.setSectionResizeMode = \ + Qt._QtGui.QHeaderView.setResizeMode + + if hasattr(Qt, "_QtCore"): + Qt.__binding_version__ = Qt._QtCore.PYQT_VERSION_STR + Qt.__qt_version__ = Qt._QtCore.QT_VERSION_STR + Qt.QtCompat.dataChanged = ( + lambda self, topleft, bottomright, roles=None: + self.dataChanged.emit(topleft, bottomright) + ) + + _reassign_misplaced_members("PyQt4") + + # QFileDialog QtCompat decorator + def _standardizeQFileDialog(some_function): + """Decorator that makes PyQt4 return conform to other bindings""" + def wrapper(*args, **kwargs): + ret = (some_function(*args, **kwargs)) + + # PyQt4 only returns the selected filename, force it to a + # standard return of the selected filename, and a empty string + # for the selected filter + return ret, '' + + wrapper.__doc__ = some_function.__doc__ + wrapper.__name__ = some_function.__name__ + + return wrapper + + decorators = { + "QFileDialog": { + "getOpenFileName": _standardizeQFileDialog, + "getOpenFileNames": _standardizeQFileDialog, + "getSaveFileName": _standardizeQFileDialog, + } + } + _build_compatibility_members('PyQt4', decorators) + + +def _none(): + """Internal option (used in installer)""" + + Mock = type("Mock", (), {"__getattr__": lambda Qt, attr: None}) + + Qt.__binding__ = "None" + Qt.__qt_version__ = "0.0.0" + Qt.__binding_version__ = "0.0.0" + Qt.QtCompat.loadUi = lambda uifile, baseinstance=None: None + Qt.QtCompat.setSectionResizeMode = lambda *args, **kwargs: None + + for submodule in _common_members.keys(): + setattr(Qt, submodule, Mock()) + setattr(Qt, "_" + submodule, Mock()) + + +def _log(text): + if QT_VERBOSE: + sys.stdout.write("Qt.py [info]: %s\n" % text) + + +def _warn(text): + try: + sys.stderr.write("Qt.py [warning]: %s\n" % text) + except UnicodeDecodeError: + import locale + encoding = locale.getpreferredencoding() + sys.stderr.write("Qt.py [warning]: %s\n" % text.decode(encoding)) + + +def _convert(lines): + """Convert compiled .ui file from PySide2 to Qt.py + + Arguments: + lines (list): Each line of of .ui file + + Usage: + >> with open("myui.py") as f: + .. lines = _convert(f.readlines()) + + """ + + def parse(line): + line = line.replace("from PySide2 import", "from Qt import QtCompat,") + line = line.replace("QtWidgets.QApplication.translate", + "QtCompat.translate") + if "QtCore.SIGNAL" in line: + raise NotImplementedError("QtCore.SIGNAL is missing from PyQt5 " + "and so Qt.py does not support it: you " + "should avoid defining signals inside " + "your ui files.") + return line + + parsed = list() + for line in lines: + line = parse(line) + parsed.append(line) + + return parsed + + +def _cli(args): + """Qt.py command-line interface""" + import argparse + + parser = argparse.ArgumentParser() + parser.add_argument("--convert", + help="Path to compiled Python module, e.g. my_ui.py") + parser.add_argument("--compile", + help="Accept raw .ui file and compile with native " + "PySide2 compiler.") + parser.add_argument("--stdout", + help="Write to stdout instead of file", + action="store_true") + parser.add_argument("--stdin", + help="Read from stdin instead of file", + action="store_true") + + args = parser.parse_args(args) + + if args.stdout: + raise NotImplementedError("--stdout") + + if args.stdin: + raise NotImplementedError("--stdin") + + if args.compile: + raise NotImplementedError("--compile") + + if args.convert: + sys.stdout.write("#\n" + "# WARNING: --convert is an ALPHA feature.\n#\n" + "# See https://github.com/mottosso/Qt.py/pull/132\n" + "# for details.\n" + "#\n") + + # + # ------> Read + # + with open(args.convert) as f: + lines = _convert(f.readlines()) + + backup = "%s_backup%s" % os.path.splitext(args.convert) + sys.stdout.write("Creating \"%s\"..\n" % backup) + shutil.copy(args.convert, backup) + + # + # <------ Write + # + with open(args.convert, "w") as f: + f.write("".join(lines)) + + sys.stdout.write("Successfully converted \"%s\"\n" % args.convert) + + +class MissingMember(object): + """ + A placeholder type for a missing Qt object not + included in Qt.py + + Args: + name (str): The name of the missing type + details (str): An optional custom error message + """ + ERR_TMPL = ("{} is not a common object across PySide2 " + "and the other Qt bindings. It is not included " + "as a common member in the Qt.py layer") + + def __init__(self, name, details=''): + self.__name = name + self.__err = self.ERR_TMPL.format(name) + + if details: + self.__err = "{}: {}".format(self.__err, details) + + def __repr__(self): + return "<{}: {}>".format(self.__class__.__name__, self.__name) + + def __getattr__(self, name): + raise NotImplementedError(self.__err) + + def __call__(self, *a, **kw): + raise NotImplementedError(self.__err) + + +def _install(): + # Default order (customize order and content via QT_PREFERRED_BINDING) + default_order = ("PySide6", "PySide2", "PyQt5", "PySide", "PyQt4") + preferred_order = None + if QT_PREFERRED_BINDING_JSON: + # A per-vendor preferred binding customization was defined + # This should be a dictionary of the full Qt.py module namespace to + # apply binding settings to. The "default" key can be used to apply + # custom bindings to all modules not explicitly defined. If the json + # data is invalid this will raise a exception. + # Example: + # {"mylibrary.vendor.Qt": ["PySide2"], "default":["PyQt5","PyQt4"]} + try: + preferred_bindings = json.loads(QT_PREFERRED_BINDING_JSON) + except ValueError: + # Python 2 raises ValueError, Python 3 raises json.JSONDecodeError + # a subclass of ValueError + _warn("Failed to parse QT_PREFERRED_BINDING_JSON='%s'" + % QT_PREFERRED_BINDING_JSON) + _warn("Falling back to default preferred order") + else: + preferred_order = preferred_bindings.get(__name__) + # If no matching binding was used, optionally apply a default. + if preferred_order is None: + preferred_order = preferred_bindings.get("default", None) + if preferred_order is None: + # If a json preferred binding was not used use, respect the + # QT_PREFERRED_BINDING environment variable if defined. + preferred_order = list( + b for b in QT_PREFERRED_BINDING.split(os.pathsep) if b + ) + + order = preferred_order or default_order + + available = { + "PySide6": _pyside6, + "PySide2": _pyside2, + "PyQt5": _pyqt5, + "PySide": _pyside, + "PyQt4": _pyqt4, + "None": _none + } + + _log("Order: '%s'" % "', '".join(order)) + + # Allow site-level customization of the available modules. + _apply_site_config() + + found_binding = False + for name in order: + _log("Trying %s" % name) + + try: + available[name]() + found_binding = True + break + + except ImportError as e: + _log("ImportError: %s" % e) + + except KeyError: + _log("ImportError: Preferred binding '%s' not found." % name) + + if not found_binding: + # If not binding were found, throw this error + raise ImportError("No Qt binding were found.") + + # Install individual members + for name, members in _common_members.items(): + try: + their_submodule = getattr(Qt, "_%s" % name) + except AttributeError: + continue + + our_submodule = getattr(Qt, name) + + # Enable import * + __all__.append(name) + + # Enable direct import of submodule, + # e.g. import Qt.QtCore + sys.modules[__name__ + "." + name] = our_submodule + + for member in members: + # Accept that a submodule may miss certain members. + try: + their_member = getattr(their_submodule, member) + except AttributeError: + _log("'%s.%s' was missing." % (name, member)) + continue + + setattr(our_submodule, member, their_member) + + # Install missing member placeholders + for name, members in _missing_members.items(): + our_submodule = getattr(Qt, name) + + for member in members: + + # If the submodule already has this member installed, + # either by the common members, or the site config, + # then skip installing this one over it. + if hasattr(our_submodule, member): + continue + + placeholder = MissingMember("{}.{}".format(name, member), + details=members[member]) + setattr(our_submodule, member, placeholder) + + # Enable direct import of QtCompat + sys.modules[__name__ + ".QtCompat"] = Qt.QtCompat + + # Backwards compatibility + if hasattr(Qt.QtCompat, 'loadUi'): + Qt.QtCompat.load_ui = Qt.QtCompat.loadUi + + +_install() + +# Setup Binding Enum states +Qt.IsPySide6 = Qt.__binding__ == "PySide6" +Qt.IsPySide2 = Qt.__binding__ == 'PySide2' +Qt.IsPyQt5 = Qt.__binding__ == 'PyQt5' +Qt.IsPySide = Qt.__binding__ == 'PySide' +Qt.IsPyQt4 = Qt.__binding__ == 'PyQt4' + +"""Augment QtCompat + +QtCompat contains wrappers and added functionality +to the original bindings, such as the CLI interface +and otherwise incompatible members between bindings, +such as `QHeaderView.setSectionResizeMode`. + +""" + +Qt.QtCompat._cli = _cli +Qt.QtCompat._convert = _convert + +# Enable command-line interface +if __name__ == "__main__": + _cli(sys.argv[1:]) + + +# The MIT License (MIT) +# +# Copyright (c) 2016-2017 Marcus Ottosson +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +# In PySide(2), loadUi does not exist, so we implement it +# +# `_UiLoader` is adapted from the qtpy project, which was further influenced +# by qt-helpers which was released under a 3-clause BSD license which in turn +# is based on a solution at: +# +# - https://gist.github.com/cpbotha/1b42a20c8f3eb9bb7cb8 +# +# The License for this code is as follows: +# +# qt-helpers - a common front-end to various Qt modules +# +# Copyright (c) 2015, Chris Beaumont and Thomas Robitaille +# +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are +# met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the +# distribution. +# * Neither the name of the Glue project nor the names of its contributors +# may be used to endorse or promote products derived from this software +# without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +# IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +# PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# Which itself was based on the solution at +# +# https://gist.github.com/cpbotha/1b42a20c8f3eb9bb7cb8 +# +# which was released under the MIT license: +# +# Copyright (c) 2011 Sebastian Wiesner +# Modifications by Charl Botha +# +# Permission is hereby granted, free of charge, to any person obtaining a +# copy of this software and associated documentation files +# (the "Software"),to deal in the Software without restriction, +# including without limitation +# the rights to use, copy, modify, merge, publish, distribute, sublicense, +# and/or sell copies of the Software, and to permit persons to whom the +# Software is furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included +# in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/plugins/__init__.py b/plugins/__init__.py new file mode 100644 index 0000000..331d588 --- /dev/null +++ b/plugins/__init__.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +NexusLauncher 插件系统 +提供 DCC 软件的插件启动功能 +""" +import os +from typing import Optional, Dict + +# 导入模块 +from plugins.maya import launch_maya +from plugins.substancepainter import launch_substance_painter +from config.constants import APP_ICON_MAPPING + + +class PluginLauncher: + """插件启动器 - 统一的启动接口""" + + @staticmethod + def _get_app_type(app_name: str) -> Optional[str]: + """通过 APP_ICON_MAPPING 识别应用类型 + + Args: + app_name: 应用名称 + + Returns: + 应用类型 (Maya, SubstancePainter 等) 或 None + """ + app_name_lower = app_name.lower() + + # 遍历映射表查找匹配 + for key, app_type in APP_ICON_MAPPING.items(): + if key in app_name_lower: + return app_type + + return None + + @staticmethod + def launch_with_plugins(app_name: str, app_path: str, plugin_config: Optional[Dict[str, str]] = None, + project_name: str = "NexusLauncher") -> bool: + """根据应用类型启动应用并加载插件 + + Args: + app_name: 应用名称 + app_path: 应用可执行文件路径 + plugin_config: 插件配置字典,包含 maya_plugin_path, sp_shelf_path 等 + project_name: 项目名称,用于 SP 库名称等 + + Returns: + 是否成功启动 + """ + if not plugin_config: + print(f"[INFO] No plugin config provided, launching {app_name} normally") + return False + + # 通过 APP_ICON_MAPPING 识别应用类型 + app_type = PluginLauncher._get_app_type(app_name) + + if not app_type: + print(f"[INFO] Unknown app type for {app_name}, launching normally") + return False + + print(f"[INFO] Detected app type: {app_type}") + + # Maya + if app_type == "Maya": + maya_plugin_path = plugin_config.get('maya_plugin_path') + if maya_plugin_path: + print(f"[INFO] Launching Maya with plugins from: {maya_plugin_path}") + return launch_maya(app_path, maya_plugin_path) + else: + print(f"[WARNING] maya_plugin_path not found in config") + return False + + # Substance Painter + elif app_type == "SubstancePainter": + sp_shelf_path = plugin_config.get('sp_shelf_path') + if sp_shelf_path: + print(f"[INFO] Launching Substance Painter with shelf: {sp_shelf_path}") + print(f"[INFO] Project: {project_name}") + return launch_substance_painter(app_path, sp_shelf_path, project_name) + else: + print(f"[WARNING] sp_shelf_path not found in config") + return False + + # 不支持的应用类型 + else: + print(f"[INFO] No plugin support for {app_type}, launching normally") + return False + + +__all__ = ['PluginLauncher', 'launch_maya', 'launch_substance_painter'] diff --git a/plugins/maya.py b/plugins/maya.py new file mode 100644 index 0000000..20d4ae9 --- /dev/null +++ b/plugins/maya.py @@ -0,0 +1,245 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Maya 插件启动模块 +负责检测 Maya 版本并设置正确的插件路径 +""" +import os +import re +import subprocess +from typing import Dict, Optional, Tuple, List + +# 尝试导入 Qt,使用 Qt.py 模块实现跨版本兼容 +try: + from .Qt import QtWidgets + QMessageBox = QtWidgets.QMessageBox + HAS_QT = True +except ImportError: + HAS_QT = False + QMessageBox = None + + +class MayaLauncher: + """Maya 启动器""" + + def __init__(self, maya_exe_path: str, plugin_base_path: str): + """ + 初始化 Maya 启动器 + + Args: + maya_exe_path: Maya 可执行文件路径 + plugin_base_path: 插件基础路径(不含版本号) + """ + self.maya_exe_path = maya_exe_path + self.plugin_base_path = plugin_base_path + self.maya_version = self._detect_maya_version() + + def _detect_maya_version(self) -> Optional[str]: + """从 Maya 路径中检测版本号 + + 支持的格式: + - Maya2023 -> 2023 + - Maya2023.2 -> 2023 + - Maya2025.0.1 -> 2025 + + Returns: + 主版本号字符串,如 "2023", "2025" 等,如果检测失败返回 None + """ + # 从路径中提取版本号(支持 Maya2023, Maya2023.2, Maya2025.0.1 等格式) + match = re.search(r'Maya(\d{4})(?:\.\d+)*', self.maya_exe_path, re.IGNORECASE) + if match: + version = match.group(1) # 只取主版本号 + print(f"[INFO] Detected Maya major version: {version}") + return version + + print(f"[WARNING] Could not detect Maya version from path: {self.maya_exe_path}") + return None + + def _get_available_plugin_versions(self) -> List[str]: + """获取所有可用的插件版本 + + Returns: + 可用版本列表,如 ['2023', '2024', '2025'] + """ + if not os.path.exists(self.plugin_base_path): + return [] + + versions = [] + try: + for item in os.listdir(self.plugin_base_path): + item_path = os.path.join(self.plugin_base_path, item) + # 检查是否是目录且名称是4位数字(版本号) + if os.path.isdir(item_path) and re.match(r'^\d{4}$', item): + versions.append(item) + except Exception as e: + print(f"[WARNING] Error scanning plugin versions: {e}") + + return sorted(versions) + + def _get_versioned_plugin_path(self) -> Tuple[Optional[str], Optional[str]]: + """获取带版本号的插件路径 + + Returns: + (插件路径, 错误消息) 元组 + - 如果成功: (路径, None) + - 如果失败: (None, 错误消息) + """ + if not self.maya_version: + return None, "无法检测 Maya 版本号" + + # 拼接版本号路径 + versioned_path = os.path.join(self.plugin_base_path, self.maya_version) + + # 检查路径是否存在 + if not os.path.exists(versioned_path): + # 获取所有可用版本 + available_versions = self._get_available_plugin_versions() + + if not available_versions: + error_msg = ( + f"插件目录不存在:\n{self.plugin_base_path}\n\n" + f"未找到任何可用的插件版本。" + ) + else: + error_msg = ( + f"Maya 版本: {self.maya_version}\n" + f"插件版本不匹配!\n\n" + f"当前 Maya 版本: {self.maya_version}\n" + f"可用插件版本: {', '.join(available_versions)}\n\n" + f"插件将不会被加载。\n" + f"请检查 Maya 软件版本或安装对应版本的插件。" + ) + + return None, error_msg + + print(f"[INFO] Using plugin path: {versioned_path}") + return versioned_path, None + + def _setup_environment(self) -> Tuple[Dict[str, str], Optional[str]]: + """设置 Maya 环境变量 + + Returns: + (环境变量字典, 错误消息) 元组 + """ + env = os.environ.copy() + + # 获取带版本号的插件路径 + plugin_path, error_msg = self._get_versioned_plugin_path() + + if not plugin_path: + return env, error_msg + + # 设置 Maya 环境变量 + shelves_path = os.path.join(plugin_path, "shelves") + scripts_path = os.path.join(plugin_path, "scripts") + plugins_path = os.path.join(plugin_path, "plug-ins") + icons_path = os.path.join(plugin_path, "icons") + + # MAYA_SHELF_PATH - 工具架路径(追加到现有路径) + if os.path.exists(shelves_path): + existing_shelf_path = env.get("MAYA_SHELF_PATH", "") + if existing_shelf_path: + env["MAYA_SHELF_PATH"] = f"{shelves_path};{existing_shelf_path}" + else: + env["MAYA_SHELF_PATH"] = shelves_path + print(f"[OK] Set MAYA_SHELF_PATH: {shelves_path}") + + # MAYA_SCRIPT_PATH - MEL/Python 脚本路径(追加到现有路径) + if os.path.exists(scripts_path): + existing_script_path = env.get("MAYA_SCRIPT_PATH", "") + if existing_script_path: + env["MAYA_SCRIPT_PATH"] = f"{scripts_path};{existing_script_path}" + else: + env["MAYA_SCRIPT_PATH"] = scripts_path + print(f"[OK] Set MAYA_SCRIPT_PATH: {scripts_path}") + + # PYTHONPATH - Python 模块路径 + if os.path.exists(scripts_path): + existing_pythonpath = env.get("PYTHONPATH", "") + if existing_pythonpath: + env["PYTHONPATH"] = f"{scripts_path};{existing_pythonpath}" + else: + env["PYTHONPATH"] = scripts_path + print(f"[OK] Set PYTHONPATH: {scripts_path}") + + # MAYA_PLUG_IN_PATH - 插件路径(追加到现有路径) + if os.path.exists(plugins_path): + existing_plugin_path = env.get("MAYA_PLUG_IN_PATH", "") + if existing_plugin_path: + env["MAYA_PLUG_IN_PATH"] = f"{plugins_path};{existing_plugin_path}" + else: + env["MAYA_PLUG_IN_PATH"] = plugins_path + print(f"[OK] Set MAYA_PLUG_IN_PATH: {plugins_path}") + + # XBMLANGPATH - 图标路径(追加到现有路径) + if os.path.exists(icons_path): + existing_icon_path = env.get("XBMLANGPATH", "") + if existing_icon_path: + env["XBMLANGPATH"] = f"{icons_path};{existing_icon_path}" + else: + env["XBMLANGPATH"] = icons_path + print(f"[OK] Set XBMLANGPATH: {icons_path}") + + return env, None + + def launch(self, show_error_dialog: bool = True) -> bool: + """启动 Maya + + Args: + show_error_dialog: 是否显示错误对话框 + + Returns: + 是否成功启动 + """ + try: + # 检查 Maya 可执行文件是否存在 + if not os.path.exists(self.maya_exe_path): + error_msg = f"Maya 可执行文件不存在:\n{self.maya_exe_path}" + print(f"[ERROR] {error_msg}") + if show_error_dialog and HAS_QT: + QMessageBox.critical(None, "Maya 启动失败", error_msg) + return False + + # 设置环境变量 + env, error_msg = self._setup_environment() + + # 如果有错误消息,显示警告但继续启动(不加载插件) + if error_msg: + print(f"[WARNING] {error_msg}") + if show_error_dialog and HAS_QT: + QMessageBox.warning( + None, + "插件版本不匹配", + error_msg + "\n\nMaya 将在不加载插件的情况下启动。" + ) + + # 启动 Maya + print(f"[INFO] Launching Maya: {self.maya_exe_path}") + subprocess.Popen([self.maya_exe_path], env=env) + + if error_msg: + print(f"[OK] Maya launched without plugins") + else: + print(f"[OK] Maya launched successfully with plugins") + return True + + except Exception as e: + error_msg = f"启动 Maya 时发生错误:\n{str(e)}" + print(f"[ERROR] {error_msg}") + if show_error_dialog and HAS_QT: + QMessageBox.critical(None, "Maya 启动失败", error_msg) + return False + + +def launch_maya(maya_exe_path: str, plugin_base_path: str) -> bool: + """启动 Maya 的便捷函数 + + Args: + maya_exe_path: Maya 可执行文件路径 + plugin_base_path: 插件基础路径(不含版本号) + + Returns: + 是否成功启动 + """ + launcher = MayaLauncher(maya_exe_path, plugin_base_path) + return launcher.launch() diff --git a/plugins/substancepainter/__init__.py b/plugins/substancepainter/__init__.py new file mode 100644 index 0000000..660bb94 --- /dev/null +++ b/plugins/substancepainter/__init__.py @@ -0,0 +1,9 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Substance Painter 插件模块 +""" + +from .launcher import SubstancePainterLauncher, launch_substance_painter + +__all__ = ['SubstancePainterLauncher', 'launch_substance_painter'] diff --git a/plugins/substancepainter/launcher.py b/plugins/substancepainter/launcher.py new file mode 100644 index 0000000..7ba5d54 --- /dev/null +++ b/plugins/substancepainter/launcher.py @@ -0,0 +1,435 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Substance Painter 启动器 +负责设置 SP 库路径和自动配置 +""" +import os +import json +import subprocess +import shutil +import tempfile +import time +import psutil +from typing import Dict, Optional +from pathlib import Path +from .registry_manager import SPRegistryManager + + +class SubstancePainterLauncher: + """Substance Painter 启动器""" + + def __init__(self, sp_exe_path: str, shelf_path: str, project_name: str = "NexusLauncher"): + """ + 初始化 Substance Painter 启动器 + + Args: + sp_exe_path: Substance Painter 可执行文件路径 + shelf_path: 库路径(sp_shelf_path) + project_name: 项目名称,用作库名称 + """ + self.sp_exe_path = sp_exe_path + self.shelf_path = shelf_path + self.project_name = project_name + + # SP 配置文件路径 + self.sp_config_dir = self._get_sp_config_dir() + + def _get_sp_config_dir(self) -> Optional[Path]: + """获取 Substance Painter 配置目录 + + Returns: + 配置目录路径,如果找不到返回 None + """ + # 方法1: 使用 USERPROFILE + userprofile = os.environ.get('USERPROFILE', '') + if userprofile: + documents = Path(userprofile) / 'Documents' + else: + documents = Path.home() / 'Documents' + + # 方法2: 使用注册表获取文档路径 + try: + import winreg + key = winreg.OpenKey( + winreg.HKEY_CURRENT_USER, + r"Software\Microsoft\Windows\CurrentVersion\Explorer\Shell Folders" + ) + documents_path, _ = winreg.QueryValueEx(key, "Personal") + winreg.CloseKey(key) + documents = Path(documents_path) + print(f"[SubstancePainter] Documents folder (from registry): {documents}") + except Exception as e: + print(f"[SubstancePainter] Could not get documents from registry: {e}") + + # 尝试查找 Substance Painter 配置目录 + sp_dirs = [ + # SP 2023+ + documents / 'Adobe' / 'Adobe Substance 3D Painter', + # 旧版本 + documents / 'Allegorithmic' / 'Substance Painter', + # 备用路径 + Path(userprofile) / 'AppData' / 'Local' / 'Adobe' / 'Adobe Substance 3D Painter', + Path(userprofile) / 'AppData' / 'Roaming' / 'Adobe' / 'Adobe Substance 3D Painter', + ] + + print(f"[SubstancePainter] Searching for config directory...") + for sp_dir in sp_dirs: + print(f"[SubstancePainter] Checking: {sp_dir}") + if sp_dir.exists(): + print(f"[SubstancePainter] ✓ Found config directory: {sp_dir}") + + # 列出配置目录中的文件 + try: + config_files = list(sp_dir.glob('*.json')) + if config_files: + print(f"[SubstancePainter] Config files found:") + for f in config_files: + print(f"[SubstancePainter] - {f.name}") + except Exception as e: + print(f"[SubstancePainter] Could not list config files: {e}") + + return sp_dir + + # 如果找不到,尝试创建默认目录 + default_dir = documents / 'Adobe' / 'Adobe Substance 3D Painter' + print(f"[SubstancePainter] Config directory not found") + print(f"[SubstancePainter] Will create default directory: {default_dir}") + + try: + default_dir.mkdir(parents=True, exist_ok=True) + print(f"[SubstancePainter] ✓ Created config directory") + return default_dir + except Exception as e: + print(f"[SubstancePainter] ✗ Failed to create config directory: {e}") + return None + + def _get_shelf_config_path(self) -> Optional[Path]: + """获取库配置文件路径 + + Returns: + 库配置文件路径,如果找不到返回 None + """ + if not self.sp_config_dir: + return None + + # SP 库配置文件可能的位置 + possible_configs = [ + self.sp_config_dir / 'shelf.json', + self.sp_config_dir / 'shelves.json', + self.sp_config_dir / 'assets.json', + self.sp_config_dir / 'preferences.json', + ] + + # 先检查是否已存在配置文件 + for config_path in possible_configs: + if config_path.exists(): + print(f"[SubstancePainter] Found existing config: {config_path.name}") + return config_path + + # 如果都不存在,使用默认的 shelf.json + shelf_config = self.sp_config_dir / 'shelf.json' + print(f"[SubstancePainter] Will create new config: {shelf_config.name}") + + return shelf_config + + def _create_default_shelf_config(self) -> dict: + """创建默认的库配置 + + Returns: + 默认库配置字典 + """ + # SP 的库配置格式(基于官方文档) + # 库会自动创建标准文件夹结构: + # alphas, colorluts, effects, environments, generators, materials, etc. + + # SP 库名称规则:只能包含小写字母、数字、下划线和连字符 + import re + safe_name = self.project_name.lower().replace(" ", "_") + safe_name = re.sub(r'[^a-z0-9_-]', '', safe_name) + + return { + "libraries": [ + { + "name": safe_name, + "path": self.shelf_path.replace("\\", "/"), + "default": True + } + ] + } + + def _update_shelf_config(self) -> bool: + """更新 Substance Painter 库配置 + + Returns: + 是否成功更新 + """ + try: + shelf_config_path = self._get_shelf_config_path() + + if not shelf_config_path: + print(f"[SubstancePainter] Warning: Cannot update shelf config (config path not found)") + print(f"[SubstancePainter] SP will start with default library settings") + return False # 返回 False 但不影响启动 + + # 读取现有配置(如果存在) + if shelf_config_path.exists(): + try: + with open(shelf_config_path, 'r', encoding='utf-8') as f: + config = json.load(f) + print(f"[SubstancePainter] Loaded existing config from: {shelf_config_path.name}") + except Exception as e: + print(f"[SubstancePainter] Failed to load existing config: {e}") + config = self._create_default_shelf_config() + else: + print(f"[SubstancePainter] Creating new config") + config = self._create_default_shelf_config() + + # 确保有 libraries 列表(SP 使用 "libraries" 而不是 "shelves") + if "libraries" not in config: + config["libraries"] = [] + + # 清理项目名称(移除空格和特殊字符,转为小写) + # SP 库名称规则:只能包含小写字母、数字、下划线和连字符 + import re + clean_project_name = self.project_name.lower().replace(" ", "_") + clean_project_name = re.sub(r'[^a-z0-9_-]', '', clean_project_name) + + # 查找是否已存在同名库 + existing_library = None + for lib in config["libraries"]: + if lib.get("name") == clean_project_name: + existing_library = lib + break + + if existing_library: + # 更新现有库 + print(f"[SubstancePainter] Updating existing library: {clean_project_name}") + existing_library["path"] = self.shelf_path.replace("\\", "/") + existing_library["default"] = True + else: + # 添加新库 + print(f"[SubstancePainter] Adding new library: {clean_project_name}") + config["libraries"].append({ + "name": clean_project_name, + "path": self.shelf_path.replace("\\", "/"), + "default": True + }) + + # 将其他库设置为非默认 + for lib in config["libraries"]: + if lib.get("name") != clean_project_name: + lib["default"] = False + + # 保存配置 + shelf_config_path.parent.mkdir(parents=True, exist_ok=True) + with open(shelf_config_path, 'w', encoding='utf-8') as f: + json.dump(config, f, indent=2, ensure_ascii=False) + + print(f"[SubstancePainter] ✓ Shelf config updated successfully") + print(f"[SubstancePainter] Library: {self.project_name}") + print(f"[SubstancePainter] Path: {self.shelf_path}") + print(f"[SubstancePainter] Default: True") + + return True + + except Exception as e: + print(f"[SubstancePainter] Error updating shelf config: {e}") + import traceback + traceback.print_exc() + return False + + def _create_library_structure(self) -> bool: + """创建 SP 库的标准文件夹结构 + + Returns: + 是否成功创建 + """ + try: + # SP 库的标准文件夹结构 + standard_folders = [ + 'alphas', # Alpha 贴图 + 'brushes', # 笔刷预设 + 'colorluts', # 颜色查找表 + 'effects', # 滤镜/效果 + 'environments', # 环境贴图 + 'generators', # 生成器 + 'materials', # 材质 + 'particles', # 粒子预设 + 'presets', # 预设 + 'shaders', # 着色器 + 'smart-materials', # 智能材质 + 'smart-masks', # 智能蒙版 + 'textures', # 纹理 + 'export-presets', # 导出预设 + ] + + # 确保使用绝对路径 + base_path = Path(self.shelf_path).resolve() + print(f"[SubstancePainter] Library base path: {base_path}") + + # 创建基础目录 + if not base_path.exists(): + print(f"[SubstancePainter] Creating library directory...") + base_path.mkdir(parents=True, exist_ok=True) + + # 创建标准文件夹 + created_folders = [] + for folder in standard_folders: + folder_path = base_path / folder + if not folder_path.exists(): + folder_path.mkdir(exist_ok=True) + created_folders.append(folder) + + if created_folders: + print(f"[SubstancePainter] Created {len(created_folders)} standard folders") + print(f"[SubstancePainter] ✓ Library structure ready") + else: + print(f"[SubstancePainter] ✓ Library structure already exists") + + return True + + except Exception as e: + print(f"[SubstancePainter] Error creating library structure: {e}") + return False + + def _setup_environment(self) -> Dict[str, str]: + """设置 Substance Painter 环境变量 + + Returns: + 包含环境变量的字典 + """ + env = os.environ.copy() + + # 创建库文件夹结构 + self._create_library_structure() + + # 设置环境变量供 SP Python 插件使用 + # SP 库名称规则:只能包含小写字母、数字、下划线和连字符 + import re + clean_project_name = self.project_name.lower().replace(" ", "_") + clean_project_name = re.sub(r'[^a-z0-9_-]', '', clean_project_name) + env["NEXUS_SP_LIBRARY_NAME"] = clean_project_name + env["NEXUS_SP_LIBRARY_PATH"] = self.shelf_path + print(f"[SubstancePainter] Set NEXUS_SP_LIBRARY_NAME: {clean_project_name}") + print(f"[SubstancePainter] Set NEXUS_SP_LIBRARY_PATH: {self.shelf_path}") + + # 创建临时插件目录并复制插件 + try: + # 创建临时目录 + temp_plugin_dir = Path(tempfile.gettempdir()) / "NexusLauncher_SP_Plugins" + temp_plugin_dir.mkdir(exist_ok=True) + + # 复制插件文件 + plugin_source = Path(__file__).parent / "sp_api_plugin.py" + plugin_dest = temp_plugin_dir / "sp_api_plugin.py" + + if plugin_source.exists(): + shutil.copy2(plugin_source, plugin_dest) + print(f"[SubstancePainter] Copied plugin to: {plugin_dest}") + + # 设置 SP 插件路径 + env["SUBSTANCE_PAINTER_PLUGINS_PATH"] = str(temp_plugin_dir) + print(f"[SubstancePainter] Set SUBSTANCE_PAINTER_PLUGINS_PATH: {temp_plugin_dir}") + else: + print(f"[SubstancePainter] Warning: Plugin source not found: {plugin_source}") + + except Exception as e: + print(f"[SubstancePainter] Warning: Failed to setup plugin: {e}") + + return env + + def _is_sp_running(self) -> bool: + """检查 SP 是否正在运行""" + for proc in psutil.process_iter(['name']): + try: + if 'Adobe Substance 3D Painter' in proc.info['name']: + return True + except: + pass + return False + + def _kill_sp(self): + """关闭所有 SP 进程""" + print(f"[SubstancePainter] Closing existing SP instances...") + for proc in psutil.process_iter(['name', 'pid']): + try: + if 'Adobe Substance 3D Painter' in proc.info['name']: + proc.kill() + print(f"[SubstancePainter] Killed process: {proc.info['pid']}") + except: + pass + time.sleep(2) # 等待进程完全关闭 + + def launch(self) -> bool: + """启动 Substance Painter + + Returns: + 是否成功启动 + """ + try: + # 检查 SP 可执行文件是否存在 + if not os.path.exists(self.sp_exe_path): + print(f"[SubstancePainter] Error: Executable not found: {self.sp_exe_path}") + return False + + print(f"[SubstancePainter] ========================================") + print(f"[SubstancePainter] Launching Substance Painter") + print(f"[SubstancePainter] ========================================") + print(f"[SubstancePainter] Project: {self.project_name}") + print(f"[SubstancePainter] Shelf Path: {self.shelf_path}") + print(f"") + + # 步骤 1: 清理旧的项目库 + print(f"[SubstancePainter] Step 1: Cleaning old project libraries...") + SPRegistryManager.remove_project_libraries() + print(f"") + + # 步骤 2: 添加当前项目库 + print(f"[SubstancePainter] Step 2: Adding current project library...") + self._create_library_structure() # 确保库文件夹存在 + success = SPRegistryManager.add_project_library(self.project_name, self.shelf_path, set_as_default=True) + if not success: + print(f"[SubstancePainter] Warning: Failed to add library to registry") + print(f"") + + # 步骤 3: 检查是否需要重启 SP + if self._is_sp_running(): + print(f"[SubstancePainter] Step 3: SP is running, restarting to apply changes...") + self._kill_sp() + time.sleep(2) + else: + print(f"[SubstancePainter] Step 3: No restart needed") + print(f"") + + # 步骤 4: 设置环境变量 + env = self._setup_environment() + + # 步骤 5: 启动 Substance Painter + print(f"[SubstancePainter] Step 4: Starting application...") + subprocess.Popen([self.sp_exe_path], env=env) + + print(f"[SubstancePainter] ✓ Substance Painter launched successfully") + print(f"[SubstancePainter] ========================================") + return True + + except Exception as e: + print(f"[SubstancePainter] Error: Failed to launch: {e}") + import traceback + traceback.print_exc() + return False + + +def launch_substance_painter(sp_exe_path: str, shelf_path: str, project_name: str = "NexusLauncher") -> bool: + """启动 Substance Painter 的便捷函数 + + Args: + sp_exe_path: Substance Painter 可执行文件路径 + shelf_path: 库路径(sp_shelf_path) + project_name: 项目名称,用作库名称 + + Returns: + 是否成功启动 + """ + launcher = SubstancePainterLauncher(sp_exe_path, shelf_path, project_name) + return launcher.launch() diff --git a/plugins/substancepainter/registry_manager.py b/plugins/substancepainter/registry_manager.py new file mode 100644 index 0000000..0544236 --- /dev/null +++ b/plugins/substancepainter/registry_manager.py @@ -0,0 +1,252 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Substance Painter 注册表管理器 +用于在启动前配置库,关闭后清理库 +""" + +import winreg +import re +import os + + +class SPRegistryManager: + """SP 注册表管理器""" + + REGISTRY_KEY = r"SOFTWARE\Adobe\Adobe Substance 3D Painter\Shelf\pathInfos" + SHELF_KEY = r"SOFTWARE\Adobe\Adobe Substance 3D Painter\Shelf" + + @staticmethod + def normalize_library_name(name): + """规范化库名称(小写、下划线、连字符)""" + safe_name = name.lower().replace(' ', '_') + safe_name = re.sub(r'[^a-z0-9_-]', '', safe_name) + return safe_name + + @staticmethod + def get_all_libraries(): + """获取所有库配置 + + Returns: + list: [(id, name, path, disabled), ...] + """ + try: + reg_conn = winreg.ConnectRegistry(None, winreg.HKEY_CURRENT_USER) + key = winreg.OpenKey(reg_conn, SPRegistryManager.REGISTRY_KEY, 0, winreg.KEY_READ) + + libraries = [] + sub_key_count = winreg.QueryInfoKey(key)[0] + + for i in range(sub_key_count): + sub_key_name = winreg.EnumKey(key, i) + sub_key = winreg.OpenKey(reg_conn, f"{SPRegistryManager.REGISTRY_KEY}\\{sub_key_name}", 0, winreg.KEY_READ) + + try: + name = winreg.QueryValueEx(sub_key, "name")[0] + path = winreg.QueryValueEx(sub_key, "path")[0] + disabled = winreg.QueryValueEx(sub_key, "disabled")[0] + libraries.append((int(sub_key_name), name, path, disabled)) + except: + pass + finally: + winreg.CloseKey(sub_key) + + winreg.CloseKey(key) + return libraries + + except Exception as e: + print(f"[Registry] Error reading libraries: {e}") + return [] + + @staticmethod + def remove_project_libraries(): + """删除所有项目库(保留系统库) + + 系统库:your_assets, starter_assets, system_fonts, user_fonts + """ + system_libs = ['your_assets', 'starter_assets', 'system_fonts', 'user_fonts'] + + try: + libraries = SPRegistryManager.get_all_libraries() + to_remove = [] + + for lib_id, name, path, disabled in libraries: + if name not in system_libs: + to_remove.append(lib_id) + print(f"[Registry] Will remove: {name} (ID: {lib_id})") + + if not to_remove: + print(f"[Registry] No project libraries to remove") + return True + + # 删除项目库 + reg_conn = winreg.ConnectRegistry(None, winreg.HKEY_CURRENT_USER) + for lib_id in to_remove: + try: + winreg.DeleteKey(winreg.HKEY_CURRENT_USER, f"{SPRegistryManager.REGISTRY_KEY}\\{lib_id}") + print(f"[Registry] Removed library ID: {lib_id}") + except Exception as e: + print(f"[Registry] Error removing {lib_id}: {e}") + + # 重新编号剩余的库(确保连续) + SPRegistryManager._reindex_libraries() + + return True + + except Exception as e: + print(f"[Registry] Error removing libraries: {e}") + return False + + @staticmethod + def _reindex_libraries(): + """重新编号库,确保 ID 连续""" + try: + libraries = SPRegistryManager.get_all_libraries() + libraries.sort(key=lambda x: x[0]) # 按 ID 排序 + + reg_conn = winreg.ConnectRegistry(None, winreg.HKEY_CURRENT_USER) + + # 如果已经连续,不需要重新编号 + expected_ids = list(range(1, len(libraries) + 1)) + actual_ids = [lib[0] for lib in libraries] + + if expected_ids == actual_ids: + print(f"[Registry] Library IDs are already continuous") + return + + print(f"[Registry] Reindexing libraries...") + + # 先将所有库移到临时 ID + temp_offset = 1000 + for lib_id, name, path, disabled in libraries: + old_key_path = f"{SPRegistryManager.REGISTRY_KEY}\\{lib_id}" + new_key_path = f"{SPRegistryManager.REGISTRY_KEY}\\{lib_id + temp_offset}" + + # 读取旧值 + old_key = winreg.OpenKey(reg_conn, old_key_path, 0, winreg.KEY_READ) + name_val = winreg.QueryValueEx(old_key, "name")[0] + path_val = winreg.QueryValueEx(old_key, "path")[0] + disabled_val = winreg.QueryValueEx(old_key, "disabled")[0] + winreg.CloseKey(old_key) + + # 创建新键 + new_key = winreg.CreateKey(winreg.HKEY_CURRENT_USER, new_key_path) + winreg.SetValueEx(new_key, "name", 0, winreg.REG_SZ, name_val) + winreg.SetValueEx(new_key, "path", 0, winreg.REG_SZ, path_val) + winreg.SetValueEx(new_key, "disabled", 0, winreg.REG_SZ, disabled_val) + winreg.CloseKey(new_key) + + # 删除旧键 + winreg.DeleteKey(winreg.HKEY_CURRENT_USER, old_key_path) + + # 再将临时 ID 移到正确的连续 ID + for new_id, (old_id, name, path, disabled) in enumerate(libraries, start=1): + temp_id = old_id + temp_offset + temp_key_path = f"{SPRegistryManager.REGISTRY_KEY}\\{temp_id}" + final_key_path = f"{SPRegistryManager.REGISTRY_KEY}\\{new_id}" + + # 读取临时键 + temp_key = winreg.OpenKey(reg_conn, temp_key_path, 0, winreg.KEY_READ) + name_val = winreg.QueryValueEx(temp_key, "name")[0] + path_val = winreg.QueryValueEx(temp_key, "path")[0] + disabled_val = winreg.QueryValueEx(temp_key, "disabled")[0] + winreg.CloseKey(temp_key) + + # 创建最终键 + final_key = winreg.CreateKey(winreg.HKEY_CURRENT_USER, final_key_path) + winreg.SetValueEx(final_key, "name", 0, winreg.REG_SZ, name_val) + winreg.SetValueEx(final_key, "path", 0, winreg.REG_SZ, path_val) + winreg.SetValueEx(final_key, "disabled", 0, winreg.REG_SZ, disabled_val) + winreg.CloseKey(final_key) + + # 删除临时键 + winreg.DeleteKey(winreg.HKEY_CURRENT_USER, temp_key_path) + + # 更新 size + key = winreg.OpenKeyEx(reg_conn, SPRegistryManager.REGISTRY_KEY, 0, winreg.KEY_SET_VALUE) + winreg.SetValueEx(key, "size", 0, winreg.REG_DWORD, len(libraries)) + winreg.CloseKey(key) + + print(f"[Registry] Reindexing complete, new size: {len(libraries)}") + + except Exception as e: + print(f"[Registry] Error reindexing: {e}") + import traceback + traceback.print_exc() + + @staticmethod + def add_project_library(library_name, library_path, set_as_default=True): + """添加项目库 + + Args: + library_name: 库名称 + library_path: 库路径 + set_as_default: 是否设置为默认 + + Returns: + bool: 是否成功 + """ + try: + safe_name = SPRegistryManager.normalize_library_name(library_name) + normalized_path = library_path.replace('\\', '/') + + print(f"[Registry] Adding library: {safe_name}") + print(f"[Registry] Path: {normalized_path}") + + # 检查是否已存在 + libraries = SPRegistryManager.get_all_libraries() + for lib_id, name, path, disabled in libraries: + if name == safe_name: + print(f"[Registry] Library already exists: {name}") + return True + + # 找到下一个 ID + if libraries: + next_id = max(lib[0] for lib in libraries) + 1 + else: + next_id = 1 + + # 创建新库 + reg_conn = winreg.ConnectRegistry(None, winreg.HKEY_CURRENT_USER) + new_key_path = f"{SPRegistryManager.REGISTRY_KEY}\\{next_id}" + new_key = winreg.CreateKey(winreg.HKEY_CURRENT_USER, new_key_path) + winreg.SetValueEx(new_key, "name", 0, winreg.REG_SZ, safe_name) + winreg.SetValueEx(new_key, "path", 0, winreg.REG_SZ, normalized_path) + winreg.SetValueEx(new_key, "disabled", 0, winreg.REG_SZ, "false") + winreg.CloseKey(new_key) + + # 更新 size + key = winreg.OpenKeyEx(reg_conn, SPRegistryManager.REGISTRY_KEY, 0, winreg.KEY_SET_VALUE) + winreg.SetValueEx(key, "size", 0, winreg.REG_DWORD, len(libraries) + 1) + winreg.CloseKey(key) + + print(f"[Registry] Library added with ID: {next_id}") + + # 设置为默认库 + if set_as_default: + shelf_key = winreg.OpenKeyEx(reg_conn, SPRegistryManager.SHELF_KEY, 0, winreg.KEY_SET_VALUE) + winreg.SetValueEx(shelf_key, "writableShelf", 0, winreg.REG_SZ, safe_name) + winreg.CloseKey(shelf_key) + print(f"[Registry] Set as default library") + + return True + + except Exception as e: + print(f"[Registry] Error adding library: {e}") + import traceback + traceback.print_exc() + return False + + @staticmethod + def needs_restart(): + """检查是否需要重启 SP 以应用更改 + + Returns: + bool: 是否需要重启 + """ + # 如果 SP 正在运行,注册表更改需要重启才能生效 + import psutil + for proc in psutil.process_iter(['name']): + if 'Adobe Substance 3D Painter' in proc.info['name']: + return True + return False diff --git a/plugins/substancepainter/sp_api_plugin.py b/plugins/substancepainter/sp_api_plugin.py new file mode 100644 index 0000000..4078db7 --- /dev/null +++ b/plugins/substancepainter/sp_api_plugin.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Substance Painter API 插件 +通过 Python API 自动添加项目库 + +此文件会被复制到临时目录,在 SP 启动时自动执行 +""" + +import os +import sys + + +def setup_project_library_via_registry(library_name, library_path): + """通过注册表设置项目库(Windows) + + Args: + library_name: 库名称 + library_path: 库路径 + + Returns: + bool: 是否成功 + """ + try: + import winreg + import re + + # SP 库名称规则:只能包含小写字母、数字、下划线和连字符 + safe_library_name = library_name.lower().replace(' ', '_') + safe_library_name = re.sub(r'[^a-z0-9_-]', '', safe_library_name) + + print(f"[NexusLauncher] Setting up library via registry: {library_name}") + if safe_library_name != library_name: + print(f"[NexusLauncher] Normalized name: {safe_library_name}") + print(f"[NexusLauncher] Path: {library_path}") + + # 检查路径是否存在 + if not os.path.exists(library_path): + print(f"[NexusLauncher] Error: Library path does not exist: {library_path}") + return False + + # 规范化路径 + normalized_path = library_path.replace('\\', '/') + + # 注册表路径 + registry_key_name = r"SOFTWARE\Adobe\Adobe Substance 3D Painter\Shelf\pathInfos" + + # 连接到注册表 + reg_connection = winreg.ConnectRegistry(None, winreg.HKEY_CURRENT_USER) + + try: + # 打开父键 + key = winreg.OpenKey(reg_connection, registry_key_name, 0, winreg.KEY_READ) + except FileNotFoundError: + print(f"[NexusLauncher] Registry key not found. Please open SP settings once to create it.") + return False + + # 检查是否已存在同名库 + sub_key_count = winreg.QueryInfoKey(key)[0] + shelf_exists = False + shelf_number = 0 + + for x in range(sub_key_count): + sub_key_name = winreg.EnumKey(key, x) + shelf_number = max(shelf_number, int(sub_key_name)) + + sub_key = winreg.OpenKey(reg_connection, registry_key_name + "\\" + sub_key_name, 0, winreg.KEY_READ) + try: + existing_name = winreg.QueryValueEx(sub_key, "name")[0] + existing_path = winreg.QueryValueEx(sub_key, "path")[0] + + if existing_name == safe_library_name: + print(f"[NexusLauncher] Library already exists: {existing_name} at {existing_path}") + shelf_exists = True + winreg.CloseKey(sub_key) + break + finally: + winreg.CloseKey(sub_key) + + if not shelf_exists: + # 添加新库 + shelf_number += 1 + print(f"[NexusLauncher] Adding new library with ID: {shelf_number}") + + # 创建新键 + new_key = winreg.CreateKey(key, str(shelf_number)) + winreg.SetValueEx(new_key, "disabled", 0, winreg.REG_SZ, "false") + winreg.SetValueEx(new_key, "name", 0, winreg.REG_SZ, safe_library_name) + winreg.SetValueEx(new_key, "path", 0, winreg.REG_SZ, normalized_path) + winreg.CloseKey(new_key) + + # 更新计数 + try: + count = winreg.QueryValueEx(key, "size")[0] + new_count = count + 1 + except: + # 如果读取失败,使用当前最大的 shelf_number + 1 + new_count = shelf_number + 1 + + winreg.CloseKey(key) + + key = winreg.OpenKeyEx(reg_connection, registry_key_name, 0, winreg.KEY_SET_VALUE) + winreg.SetValueEx(key, "size", 0, winreg.REG_DWORD, new_count) + print(f"[NexusLauncher] Updated size to: {new_count}") + + print(f"[NexusLauncher] ✓ Library added to registry successfully") + + # 设置为默认库(writableShelf) + try: + shelf_key = r"SOFTWARE\Adobe\Adobe Substance 3D Painter\Shelf" + default_key = winreg.OpenKeyEx(reg_connection, shelf_key, 0, winreg.KEY_SET_VALUE) + winreg.SetValueEx(default_key, "writableShelf", 0, winreg.REG_SZ, safe_library_name) + winreg.CloseKey(default_key) + print(f"[NexusLauncher] ✓ Set as default library (writableShelf)") + except Exception as e: + print(f"[NexusLauncher] Warning: Could not set as default: {e}") + + winreg.CloseKey(key) + else: + winreg.CloseKey(key) + + print(f"[NexusLauncher] ========================================") + print(f"[NexusLauncher] ✓ Library setup complete") + print(f"[NexusLauncher] Note: Restart SP to see the changes") + print(f"[NexusLauncher] ========================================") + + return True + + except Exception as e: + print(f"[NexusLauncher] Error: {e}") + import traceback + traceback.print_exc() + return False + + +def setup_project_library(library_name, library_path, set_as_default=True): + """设置项目库 + + Args: + library_name: 库名称 + library_path: 库路径 + set_as_default: 是否设置为默认库 + + Returns: + bool: 是否成功 + """ + try: + # 延迟导入,因为只有在 SP 中才能导入 + import substance_painter.resource + + # SP 库名称规则:只能包含小写字母、数字、下划线和连字符 + # 将名称转换为小写并替换无效字符 + safe_library_name = library_name.lower().replace(' ', '_') + # 移除其他无效字符 + import re + safe_library_name = re.sub(r'[^a-z0-9_-]', '', safe_library_name) + + print(f"[NexusLauncher] Setting up library: {library_name}") + if safe_library_name != library_name: + print(f"[NexusLauncher] Normalized name: {safe_library_name} (SP naming rules)") + print(f"[NexusLauncher] Path: {library_path}") + + # 检查路径是否存在 + if not os.path.exists(library_path): + print(f"[NexusLauncher] Error: Library path does not exist: {library_path}") + return False + + # 获取所有现有的 Shelf + existing_shelves = substance_painter.resource.Shelves.all() + print(f"[NexusLauncher] Found {len(existing_shelves)} existing shelves") + + # 检查是否已存在同名或同路径的库 + shelf_exists = False + for shelf in existing_shelves: + shelf_name = shelf.name() + shelf_path = shelf.path() + print(f"[NexusLauncher] Existing shelf: {shelf_name} -> {shelf_path}") + + # 规范化路径进行比较 + from os.path import normpath + normalized_shelf_path = normpath(shelf_path).lower() + normalized_library_path = normpath(library_path).lower() + + if shelf_name == safe_library_name or normalized_shelf_path == normalized_library_path: + print(f"[NexusLauncher] Library already exists: {shelf_name} at {shelf_path}") + shelf_exists = True + existing_shelf = shelf + break + + if not shelf_exists: + # 添加新库 + print(f"[NexusLauncher] Adding new library...") + try: + # 探索可用的 API 方法 + print(f"[NexusLauncher] Available Shelf methods: {[m for m in dir(substance_painter.resource.Shelf) if not m.startswith('_')]}") + print(f"[NexusLauncher] Available Shelves methods: {[m for m in dir(substance_painter.resource.Shelves) if not m.startswith('_')]}") + + # 检查是否有项目打开(添加库需要关闭项目) + try: + import substance_painter.project + if substance_painter.project.is_open(): + print(f"[NexusLauncher] ERROR: A project is currently open!") + print(f"[NexusLauncher] According to SP API docs, no project should be open when adding shelves") + print(f"[NexusLauncher] Please close the project and try again") + print(f"[NexusLauncher] Or add the library manually: Edit -> Settings -> Libraries") + return False + else: + print(f"[NexusLauncher] ✓ No project is open, safe to add shelf") + except Exception as e: + print(f"[NexusLauncher] Warning: Could not check project status: {e}") + + # 规范化路径为正斜杠格式(SP 可能需要这个) + normalized_path = library_path.replace('\\', '/') + print(f"[NexusLauncher] Normalized path: {normalized_path}") + + # 使用 Shelves.add 方法(使用规范化的名称) + print(f"[NexusLauncher] Calling Shelves.add('{safe_library_name}', '{normalized_path}')") + new_shelf = substance_painter.resource.Shelves.add(safe_library_name, normalized_path) + print(f"[NexusLauncher] ✓ Library added successfully") + + # 刷新资源 + substance_painter.resource.Shelves.refresh_all() + print(f"[NexusLauncher] ✓ Resources refreshed") + + except Exception as e: + print(f"[NexusLauncher] Error adding library: {e}") + import traceback + traceback.print_exc() + return False + else: + print(f"[NexusLauncher] Library already configured") + + # 设置为默认库(如果需要) + if set_as_default: + # 注意:SP API 可能没有直接设置默认库的方法 + # 这需要通过修改配置文件来实现 + print(f"[NexusLauncher] Note: Default library setting may require manual configuration") + + print(f"[NexusLauncher] ========================================") + print(f"[NexusLauncher] ✓ Library setup complete") + print(f"[NexusLauncher] ========================================") + + return True + + except ImportError as e: + print(f"[NexusLauncher] Error: Cannot import substance_painter module") + print(f"[NexusLauncher] This script must be run inside Substance Painter") + return False + except Exception as e: + print(f"[NexusLauncher] Error: {e}") + import traceback + traceback.print_exc() + return False + + +def get_library_info_from_env(): + """从环境变量获取库信息 + + Returns: + tuple: (library_name, library_path) 或 (None, None) + """ + # NexusLauncher 会设置这些环境变量 + library_name = os.environ.get('NEXUS_SP_LIBRARY_NAME') + library_path = os.environ.get('NEXUS_SP_LIBRARY_PATH') + + if library_name and library_path: + return library_name, library_path + + return None, None + + +def main(): + """主函数""" + print(f"[NexusLauncher] ========================================") + print(f"[NexusLauncher] Substance Painter Library Setup Plugin") + print(f"[NexusLauncher] ========================================") + print(f"[NexusLauncher] Plugin loaded from: {__file__}") + print(f"[NexusLauncher] Python version: {sys.version}") + + # 检查环境变量 + print(f"[NexusLauncher] Checking environment variables...") + library_name = os.environ.get('NEXUS_SP_LIBRARY_NAME') + library_path = os.environ.get('NEXUS_SP_LIBRARY_PATH') + print(f"[NexusLauncher] NEXUS_SP_LIBRARY_NAME: {library_name}") + print(f"[NexusLauncher] NEXUS_SP_LIBRARY_PATH: {library_path}") + + if not library_name or not library_path: + print(f"[NexusLauncher] Error: Library info not found in environment variables") + print(f"[NexusLauncher] Please launch SP through NexusLauncher") + return False + + # 优先使用注册表方法(更可靠) + print(f"[NexusLauncher] Method 1: Using Windows Registry...") + success = setup_project_library_via_registry(library_name, library_path) + + if not success: + print(f"[NexusLauncher] Method 2: Using Python API...") + success = setup_project_library(library_name, library_path, set_as_default=True) + + return success + + +# SP 插件入口点 +def start_plugin(): + """SP 插件启动入口点""" + main() + +def close_plugin(): + """SP 插件关闭入口点""" + pass + +# 如果作为插件运行 +if __name__ == "__plugin__": + start_plugin() + +# 如果作为脚本运行 +elif __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8ad72fd --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +customtkinter>=5.2.0 +Pillow>=10.0.0 +pystray>=0.19.0 +pyinstaller>=6.0.0 +pywin32>=306 diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000..8d5594c --- /dev/null +++ b/ui/__init__.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +UI Module +--------- +用户界面相关模块 +""" + +from .utilities import ( + MessageDialog, + InputDialog, + show_info, + show_warning, + show_error, + ask_yes_no, + ask_string, + BaseDialog, + IconManager, + get_icon_path, + set_window_icon, + get_icons_dir, + setup_dialog_icon, + setup_dialog_window, + set_dark_title_bar, + darken_color +) +from .utilities import custom_dialogs +from .settings_window import SettingsWindow +from .splash_screen import SplashScreen + +__all__ = [ + 'BaseDialog', + 'MessageDialog', + 'InputDialog', + 'show_info', + 'show_warning', + 'show_error', + 'ask_yes_no', + 'ask_string', + 'SettingsWindow', + 'SplashScreen', + 'IconManager', + 'get_icon_path', + 'set_window_icon', + 'get_icons_dir', + 'setup_dialog_icon', + 'setup_dialog_window', + 'set_dark_title_bar', + 'darken_color', + 'custom_dialogs' +] diff --git a/ui/project/__init__.py b/ui/project/__init__.py new file mode 100644 index 0000000..672073d --- /dev/null +++ b/ui/project/__init__.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""Project package for NexusLauncher. + +Exports the project management components. +""" + +from .project_panel import ProjectPanel + +__all__ = ["ProjectPanel"] diff --git a/ui/project/project_panel.py b/ui/project/project_panel.py new file mode 100644 index 0000000..c39cecc --- /dev/null +++ b/ui/project/project_panel.py @@ -0,0 +1,533 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""Project panel for NexusLauncher. + +This module contains the ProjectPanel class which manages the display +and interaction with project applications. +""" + +import os +import traceback +import customtkinter as ctk +from typing import Dict, List, Optional + +from config.constants import ( + COLOR_TRANSPARENT, TEXT_COLOR_WHITE, TEXT_COLOR_PRIMARY, TEXT_COLOR_SECONDARY, + SCROLLBAR_COLOR, SCROLLBAR_HOVER_COLOR, PRESET_COLORS +) +from ui.utilities.color_utils import darken_color +from ui.utilities import custom_dialogs +from ui.utilities.ui_helpers import UIHelpers + + +class ProjectPanel(ctk.CTkFrame): + """Project panel for displaying and managing project applications.""" + + def __init__(self, parent, config_manager, icon_manager, log_callback=None, **kwargs): + """Initialize the project panel. + + Args: + parent: Parent widget + config_manager: Configuration manager instance + icon_manager: Icon manager instance + log_callback: Optional callback for logging messages + **kwargs: Additional keyword arguments for CTkFrame + """ + super().__init__(parent, **kwargs) + + self.config_manager = config_manager + self.icon_manager = icon_manager + self.log_callback = log_callback + self.icon_size = 80 # Default icon size + self.debug_mode = False # 调试模式,可通过配置控制 + + self._setup_ui() + self._init_background_color() + + def _setup_ui(self): + """Setup the user interface.""" + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(0, weight=1) + + # Create scrollable frame for apps + self.apps_outer_frame = ctk.CTkScrollableFrame( + self, + corner_radius=10, + fg_color=COLOR_TRANSPARENT, + scrollbar_fg_color=COLOR_TRANSPARENT, + scrollbar_button_color=SCROLLBAR_COLOR, + scrollbar_button_hover_color=SCROLLBAR_HOVER_COLOR + ) + self.apps_outer_frame.grid(row=0, column=0, sticky="nsew", padx=5, pady=5) + + # Hide scrollbar after creation (with longer delay to ensure it's fully created) + self.after(100, lambda: UIHelpers.hide_scrollbar(self.apps_outer_frame)) + + # Create inner frame for app buttons + self.apps_frame = ctk.CTkFrame(self.apps_outer_frame, fg_color=COLOR_TRANSPARENT) + self.apps_frame.pack(fill="both", expand=True, padx=5, pady=5) + + def _init_background_color(self): + """初始化背景色,避免首次显示时的蓝色""" + try: + # 获取当前项目的颜色 + current_project = self.config_manager.get_current_project() + project_color = self.config_manager.get_project_color(current_project) + + if project_color: + # 如果有项目颜色,使用项目颜色 + self.apps_outer_frame.configure(fg_color=project_color) + self._log(f"Initialized background color: {project_color}", "DEBUG") + else: + # 否则使用透明色 + self.apps_outer_frame.configure(fg_color=COLOR_TRANSPARENT) + self._log("Initialized background color: transparent", "DEBUG") + except Exception as e: + self._log(f"Failed to initialize background color: {e}", "ERROR") + + def _ensure_ready_then(self, func, delay: int = 100): + """在 apps_frame 就绪后执行回调,否则延迟重试。 + Args: + func: 可调用对象,在就绪后执行 + delay: 未就绪时再次检查的延迟毫秒 + """ + try: + if not hasattr(self, 'apps_frame') or not self.apps_frame.winfo_exists(): + self._log("Frame not exists, delaying execution", "DEBUG") + self.after(delay, lambda: self._ensure_ready_then(func, delay)) + return + frame_width = self.apps_frame.winfo_width() + if frame_width <= 1: + self._log(f"Frame not ready (width={frame_width}), delaying execution", "DEBUG") + self.after(delay, lambda: self._ensure_ready_then(func, delay)) + return + # 就绪,执行回调 + func() + except Exception as e: + self._log(f"Ready check failed: {e}", "ERROR") + traceback.print_exc() + self.after(delay, lambda: self._ensure_ready_then(func, delay)) + + def _log(self, message: str, level: str = "INFO"): + """统一的日志方法 + + Args: + message: 日志消息 + level: 日志级别 (DEBUG, INFO, WARNING, ERROR) + """ + # DEBUG级别的日志只在调试模式下输出 + if level == "DEBUG" and not self.debug_mode: + return + + prefix = f"[{level}]" + full_message = f"{prefix} {message}" + + if self.log_callback: + self.log_callback(full_message) + else: + print(full_message) + + def refresh(self): + """Refresh the project panel.""" + # 确保框架已经完全初始化 + try: + # 防重入,避免多次排队刷新 + if getattr(self, '_refreshing', False): + return + self._refreshing = True + # 使用就绪助手,保持行为一致 + def _do_load(): + self._log(f"Frame ready (width={self.apps_frame.winfo_width()}), loading apps", "DEBUG") + # 强制更新布局,确保所有pending的更新完成 + self.update_idletasks() + # 延迟加载,确保UI完全稳定 + self.after(50, self._load_apps) + self._ensure_ready_then(_do_load, delay=100) + except Exception as e: + self._log(f"Refresh failed: {e}", "ERROR") + traceback.print_exc() + # 如果出错,延迟重试 + self.after(100, self.refresh) + finally: + self._refreshing = False + + def _load_apps(self): + """加载并显示应用按钮(带错误处理)""" + try: + # Clear icon cache to ensure icons are updated + self.icon_manager.clear_cache() + + # Clear existing buttons + for widget in self.apps_frame.winfo_children(): + widget.destroy() + + # Get current project's applications + apps = self.config_manager.get_apps() + + self._log(f"Loading apps, count: {len(apps) if apps else 0}", "DEBUG") + + if not apps: + self._show_empty_state() + return + + # Calculate columns per row (adaptive) + cols_per_row = self._calculate_columns_per_row() + + # Create application buttons (adaptive layout) + for idx, app in enumerate(apps): + row = idx // cols_per_row + col = idx % cols_per_row + self._create_app_button(app, row, col) + + # Hide scrollbar after loading (with longer delay) + self.after(100, lambda: UIHelpers.hide_scrollbar(self.apps_outer_frame)) + + except Exception as e: + self._log(f"Load apps failed: {e}", "ERROR") + traceback.print_exc() + + def _show_empty_state(self): + """显示空状态提示""" + empty_label = ctk.CTkLabel( + self.apps_frame, + text="暂无应用\n请点击右上角的设置按钮添加应用", + font=ctk.CTkFont(size=16, weight="bold"), + text_color=TEXT_COLOR_WHITE, + justify="center" + ) + empty_label.pack(expand=True) + + def _calculate_columns_per_row(self) -> int: + """Calculate number of columns per row based on frame width.""" + self.update_idletasks() + self.apps_frame.update_idletasks() + + # Get available width + frame_width = self.apps_frame.winfo_width() + if frame_width <= 1: + # Fallback to estimated width + window_width = self.winfo_toplevel().winfo_width() or 800 + frame_width = window_width - 30 # Subtract padding + + # Calculate columns: (width - safety margin) / (button size + spacing) + cols = (frame_width - 10) // (self.icon_size + 20) + + # Ensure at least 1 column, maximum 8 columns + return max(1, min(cols, 8)) + + def _get_default_button_color(self) -> tuple: + """Get default button color and hover color.""" + # Use first color from preset colors as default + color = PRESET_COLORS[0] # Blue-gray + + # Generate corresponding hover color (slightly darker) + hover_color = darken_color(color, 0.2) + + return color, hover_color + + def _create_app_button(self, app: Dict, row: int, col: int): + """Create an application button. + + Args: + app: Application dictionary containing name, path, version, etc. + row: Grid row position + col: Grid column position + """ + # Button container + btn_container = ctk.CTkFrame(self.apps_frame, fg_color=COLOR_TRANSPARENT) + btn_container.grid(row=row, column=col, padx=10, pady=10) + + # Get icon and color + icon_image = self.icon_manager.get_app_icon(app['path'], self.config_manager) + custom_color = self.config_manager.get_app_color(app['path']) + fg_color, hover_color = ( + (custom_color, darken_color(custom_color, 0.2)) + if custom_color + else self._get_default_button_color() + ) + + # Truncate application name + app_name = app['name'] + max_chars = max(3, self.icon_size // 10) + if len(app_name) > max_chars: + app_name = app_name[:max_chars-1] + ".." + + # Square button + app_button = ctk.CTkButton( + btn_container, + text=app_name, + image=icon_image, + command=lambda: self._launch_app(app), + width=self.icon_size, + height=self.icon_size, + corner_radius=15, + font=ctk.CTkFont(size=8, weight="bold"), + fg_color=fg_color, + hover_color=hover_color, + compound="top", + border_spacing=3, + text_color=TEXT_COLOR_PRIMARY + ) + app_button.pack(pady=(0, 3)) + + # Version label + version_label = ctk.CTkLabel( + btn_container, + text=f"v{app['version']}", + font=ctk.CTkFont(size=10), + text_color=TEXT_COLOR_WHITE + ) + version_label.pack() + + def _launch_app(self, app: Dict): + """Launch an application with plugin support. + + Args: + app: Application dictionary containing path and name + """ + app_path = app['path'] + app_name = app['name'] + + try: + # Check if path exists + if not os.path.exists(app_path): + self._log(f"Launch failed: {app_name} - Path does not exist", "ERROR") + custom_dialogs.show_error( + self, + "错误", + f"应用路径不存在:\n{app_path}\n\n请检查配置是否正确。" + ) + return + + # Try to launch with plugins + current_project = self.config_manager.get_current_project() + plugin_config = self.config_manager.get_task_settings(current_project) + + # Import plugin launcher + from plugins import PluginLauncher + + # Try plugin launch + plugin_launched = False + if plugin_config: + self._log(f"Attempting to launch {app_name} with plugins...", "DEBUG") + plugin_launched = PluginLauncher.launch_with_plugins( + app_name, + app_path, + plugin_config, + current_project # 传递项目名称 + ) + + # Fallback to normal launch if plugin launch failed or not configured + if not plugin_launched: + self._log(f"Launching {app_name} normally (no plugins)", "INFO") + if os.path.isfile(app_path): + os.startfile(app_path) + else: + os.startfile(app_path) + + self._log(f"Launched application: {app_name}", "INFO") + + except PermissionError: + self._log(f"Launch failed: {app_name} - Permission denied", "ERROR") + custom_dialogs.show_error( + self, + "权限错误", + f"没有权限启动应用 '{app_name}'\n请以管理员身份运行或检查文件权限。" + ) + except OSError as e: + self._log(f"Launch failed: {app_name} - OS error: {e}", "ERROR") + custom_dialogs.show_error( + self, + "系统错误", + f"无法启动应用 '{app_name}':\n{str(e)}" + ) + except Exception as e: + self._log(f"Launch failed: {app_name} - {str(e)}", "ERROR") + custom_dialogs.show_error( + self, + "启动失败", + f"无法启动应用 '{app_name}'\n请检查应用路径是否正确。" + ) + + def set_icon_size(self, size: int): + """Set the icon size and refresh the display. + + Args: + size: New icon size in pixels + """ + self.icon_size = size + self._load_apps() + + def update_background_color(self, color: str): + """Update the background color of the panel and its components. + + Args: + color: Background color in hex format + """ + try: + # Update the outer scrollable frame background + if hasattr(self, 'apps_outer_frame'): + self.apps_outer_frame.configure(fg_color=color) + + self._log(f"Updated background color to: {color}", "DEBUG") + except Exception as e: + self._log(f"Failed to update background color: {e}", "ERROR") + + def on_window_resize(self): + """Handle window resize events.""" + # Check if we need to recalculate layout + try: + if not hasattr(self, 'apps_frame'): + return + + # Clear existing buttons + if hasattr(self, 'apps_frame'): + for widget in self.apps_frame.winfo_children(): + widget.destroy() + + # Calculate new column count + cols_per_row = self._calculate_columns_per_row() + + # Recreate buttons with new layout + apps = self.config_manager.get_apps() + for idx, app in enumerate(apps): + row = idx // cols_per_row + col = idx % cols_per_row + self._create_app_button(app, row, col) + + except Exception as e: + self._log(f"Error during resize: {e}", "WARNING") + traceback.print_exc() + + def handle_zoom(self, event, min_icon_size=50, max_icon_size=150): + """处理 Ctrl + 鼠标滚轮缩放事件 + + Args: + event: 鼠标滚轮事件 + min_icon_size: 最小图标大小 + max_icon_size: 最大图标大小 + + Returns: + str: "break" 阻止事件继续传播 + """ + try: + self._log(f"Zoom event triggered - delta: {event.delta}, current size: {self.icon_size}", "DEBUG") + + # 获取滚轮方向(正数为向上,负数为向下) + delta = event.delta + + # 计算新的图标大小 + if delta > 0: + # 放大 + new_size = min(self.icon_size + 10, max_icon_size) + else: + # 缩小 + new_size = max(self.icon_size - 10, min_icon_size) + + self._log(f"New size: {new_size}", "DEBUG") + + # 如果大小有变化,则更新 + if new_size != self.icon_size: + self.icon_size = new_size + # 清空图标缓存,强制重新加载新尺寸的图标 + self.icon_manager.clear_cache() + # 使用防抖动机制,避免频繁刷新 + if hasattr(self, '_zoom_timer'): + self.after_cancel(self._zoom_timer) + # 延迟50ms执行,如果连续滚动则只执行最后一次 + self._zoom_timer = self.after(50, lambda: self._apply_zoom(new_size)) + self._log("Zoom triggered", "DEBUG") + else: + self._log("Size unchanged, limit reached", "DEBUG") + + # 阻止事件继续传播,避免触发滚动 + return "break" + except Exception as e: + self._log(f"Zoom error: {e}", "ERROR") + return "break" + + def _apply_zoom(self, new_size): + """应用缩放(防抖动后执行) + + Args: + new_size: 新的图标大小 + """ + try: + # 保存到配置 + self.config_manager.save_icon_size(new_size) + # 更新图标管理器的图标大小 + self.icon_manager.update_icon_size(new_size) + # 重新加载应用按钮 + self._load_apps() + except Exception as e: + self._log(f"Scaling application failed: {e}", "ERROR") + + def update_tab_icon(self, tabview, project_name): + """更新Project标签页的图标 + + Args: + tabview: 标签页视图对象 + project_name: 项目名称 + """ + try: + from PIL import Image + import customtkinter as ctk + + # 获取项目图标路径 + project_icon_path = self.config_manager.get_project_icon(project_name) + + # 获取标签页按钮 + project_button = tabview._segmented_button._buttons_dict.get("Project") + task_button = tabview._segmented_button._buttons_dict.get("Task") + + # 确保两个按钮高度一致 + if project_button: + project_button.configure(height=40) + if task_button: + task_button.configure(height=40) + + if project_button: + self._log(f"Project icon path: {project_icon_path}", "DEBUG") + self._log(f"File exists: {os.path.exists(project_icon_path) if project_icon_path else False}", "DEBUG") + + if project_icon_path and os.path.exists(project_icon_path): + # 加载项目图标 + try: + icon_image = Image.open(project_icon_path) + icon_image = icon_image.resize((20, 20), Image.Resampling.LANCZOS) + ctk_icon = ctk.CTkImage(light_image=icon_image, dark_image=icon_image, size=(20, 20)) + project_button.configure(image=ctk_icon, compound="left", text="Project") + self._log(f"Successfully loaded project icon: {project_icon_path}", "DEBUG") + except Exception as e: + self._log(f"Failed to load project icon: {e}", "WARNING") + # 加载失败时使用空的CTkImage避免引用错误 + empty_icon = self._create_empty_icon() + project_button.configure(image=empty_icon, compound="left", text="Project") + self._log("Set empty icon due to load failure", "DEBUG") + else: + # 如果没有图标,使用空的CTkImage避免引用错误 + self._log("No icon path or file doesn't exist, creating empty icon", "DEBUG") + empty_icon = self._create_empty_icon() + project_button.configure(image=empty_icon, compound="left", text="Project") + self._log("Set empty icon for project", "DEBUG") + + # 强制更新按钮显示 + project_button.update_idletasks() + if task_button: + task_button.update_idletasks() + + except Exception as e: + self._log(f"Failed to update tab icon: {e}", "ERROR") + traceback.print_exc() + + def _create_empty_icon(self): + """创建空的CTkImage,避免图像引用错误 + + Returns: + 空的CTkImage对象 + """ + from PIL import Image + import customtkinter as ctk + + empty_image = Image.new('RGBA', (1, 1), (0, 0, 0, 0)) + return ctk.CTkImage(light_image=empty_image, dark_image=empty_image, size=(1, 1)) diff --git a/ui/settings_window.py b/ui/settings_window.py new file mode 100644 index 0000000..e1427b7 --- /dev/null +++ b/ui/settings_window.py @@ -0,0 +1,2121 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +设置窗口 - 提供现代化的配置界面 +""" +import customtkinter as ctk +import os +import glob +from tkinter import filedialog +from typing import Callable +from PIL import Image +from config import ConfigManager +from config.constants import ( + # 颜色常量 + PRESET_COLORS, BORDER_COLOR, BG_COLOR_DARK, LINE_COLOR_GRAY, + BORDER_COLOR_WHITE, DIALOG_BG_COLOR, DIALOG_TEXT_COLOR, + + # 按钮颜色 + BUTTON_GRAY, BUTTON_GRAY_HOVER, BUTTON_RED, BUTTON_RED_HOVER, + BUTTON_BLUE, BUTTON_BLUE_HOVER, BUTTON_GREEN, BUTTON_GREEN_HOVER, + SAVE_BUTTON_COLOR, SAVE_BUTTON_HOVER, + + # 状态颜色 + COLOR_SUCCESS, COLOR_SUCCESS_HOVER, + + # 拖拽和选择颜色 + DRAG_HIGHLIGHT_COLOR, DRAG_HIGHLIGHT_BG, SELECTION_BORDER, SELECTION_BG, + + # 滚动条颜色 + SCROLLBAR_COLOR, SCROLLBAR_HOVER_COLOR, + + # 窗口尺寸 + SETTINGS_WINDOW_SIZE, DIALOG_INPUT_SIZE, DIALOG_CONFIRM_SIZE, + DIALOG_APP_EDIT_SIZE, DIALOG_ICON_SELECT_SIZE +) +from .utilities import custom_dialogs +from .utilities.icon_utils import get_icon_path, set_window_icon, get_icons_dir +import ctypes + +class SettingsWindow(ctk.CTkToplevel): + """设置窗口类""" + + def __init__(self, parent, config_manager: ConfigManager, on_update: Callable): + super().__init__(parent) + + self.config_manager = config_manager + self.on_update = on_update + self.debug_mode = False # 调试模式控制 + + # 拖放相关变量 + self.drag_source = None + self.drag_target = None + self.drag_data = None + self.drag_widget = None + + # 多选和复制粘贴相关变量 + self.selected_items = [] # 存储选中的应用索引 + self.clipboard_apps = [] # 剪贴板中的应用数据 + self.last_selected_index = None # 上次选中的索引,用于Shift多选 + + # 性能优化相关变量 + self._update_timer = None # 延迟更新定时器 + self._cached_projects = None # 缓存的项目列表 + self._last_project_update = 0 # 上次项目更新时间 + + # 窗口配置 + self.title("NexusLauncher - Settings") + self.geometry(SETTINGS_WINDOW_SIZE) + self.resizable(True, True) + + # 先隐藏窗口,避免加载时闪烁 + self.withdraw() + + # 设置窗口图标(在transient之前设置) + set_window_icon(self) + + # 先设置为瞬态窗口 + self.transient(parent) + + # 等待窗口创建完成 + self.update_idletasks() + + # 再次尝试设置图标(确保生效) + def set_icon_delayed(): + set_window_icon(self) + self.after(50, set_icon_delayed) + self.after(200, set_icon_delayed) + + # 创建界面 + self._create_widgets() + self._load_data() + + # 绑定基本键盘快捷键 + self.bind_all("", self._handle_delete, "+") + self.bind_all("", self._handle_escape, "+") + + # 延迟绑定全局点击事件,确保所有控件都已创建 + self.after(100, self._setup_global_click_binding) + + # 所有内容加载完成后,居中显示窗口并抓取焦点 + self._center_window() + self.deiconify() + self.grab_set() + self.focus_set() + + # 设置深色标题栏 + self.after(10, lambda: self._set_dark_title_bar(self)) + + def _log(self, message: str, level: str = "INFO"): + """统一的日志方法 + + Args: + message: 日志消息 + level: 日志级别 (DEBUG, INFO, WARNING, ERROR) + """ + # DEBUG级别的日志只在调试模式下输出 + if level == "DEBUG" and not self.debug_mode: + return + + prefix = f"[{level}]" + full_message = f"{prefix} {message}" + print(full_message) + + + def _ensure_project_selected(self) -> str: + """确保已选择项目,返回项目名称或None""" + current_project = self.project_combo.get() + if not current_project: + custom_dialogs.show_warning(self, "警告", "请先选择一个项目") + return None + return current_project + + def _create_button(self, parent, text: str, command, width: int = 120, height: int = 35, + fg_color=None, hover_color=None, font_size: int = 12): + """创建标准按钮的工厂方法""" + if fg_color is None: + fg_color = BUTTON_BLUE + if hover_color is None: + hover_color = BUTTON_BLUE_HOVER + + return ctk.CTkButton( + parent, + text=text, + command=command, + width=width, + height=height, + font=ctk.CTkFont(size=font_size), + fg_color=fg_color, + hover_color=hover_color + ) + + def _batch_operation(self, operation_func, items, operation_name: str = "操作"): + """批量操作的通用方法""" + if not items: + custom_dialogs.show_warning(self, "警告", f"没有选中的项目进行{operation_name}") + return False + + success_count = 0 + failed_count = 0 + + for item in items: + try: + if operation_func(item): + success_count += 1 + else: + failed_count += 1 + except Exception as e: + self._log(f"Batch operation error: {e}", "DEBUG") + failed_count += 1 + + # 显示结果 + if failed_count == 0: + custom_dialogs.show_info(self, "成功", f"{operation_name}完成,共处理 {success_count} 项") + else: + custom_dialogs.show_warning( + self, "部分成功", + f"{operation_name}完成,成功 {success_count} 项,失败 {failed_count} 项" + ) + + return success_count > 0 + + def _center_window(self): + """将窗口居中显示在屏幕中央""" + self.update_idletasks() + + # 获取窗口大小 + window_width = self.winfo_width() + window_height = self.winfo_height() + + # 获取屏幕大小 + screen_width = self.winfo_screenwidth() + screen_height = self.winfo_screenheight() + + # 计算居中位置 + x = (screen_width - window_width) // 2 + y = (screen_height - window_height) // 2 + + # 设置窗口位置 + self.geometry(f"{window_width}x{window_height}+{x}+{y}") + + def _set_dark_title_bar(self, window): + """设置窗口深色标题栏(Windows 10/11)""" + try: + window.update() + hwnd = ctypes.windll.user32.GetParent(window.winfo_id()) + + # DWMWA_USE_IMMERSIVE_DARK_MODE = 20 (Windows 11) + # DWMWA_USE_IMMERSIVE_DARK_MODE = 19 (Windows 10 1903+) + DWMWA_USE_IMMERSIVE_DARK_MODE = 20 + value = ctypes.c_int(1) # 1 = 深色模式 + + ctypes.windll.dwmapi.DwmSetWindowAttribute( + hwnd, + DWMWA_USE_IMMERSIVE_DARK_MODE, + ctypes.byref(value), + ctypes.sizeof(value) + ) + + # 如果 Windows 11 方式失败,尝试 Windows 10 方式 + if ctypes.windll.dwmapi.DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, ctypes.byref(value), ctypes.sizeof(value)) != 0: + DWMWA_USE_IMMERSIVE_DARK_MODE = 19 + ctypes.windll.dwmapi.DwmSetWindowAttribute( + hwnd, + DWMWA_USE_IMMERSIVE_DARK_MODE, + ctypes.byref(value), + ctypes.sizeof(value) + ) + except Exception as e: + self._log(f"Failed to set dark title bar: {e}", "DEBUG") + + def destroy(self): + """销毁设置窗口前解除全局绑定,避免潜在泄漏""" + try: + # 解除全局快捷键绑定 + self.unbind_all("") + self.unbind_all("") + except Exception as e: + self._log(f"Failed to unbind global events on destroy: {e}", "DEBUG") + # 调用父类销毁 + return super().destroy() + + def _create_custom_input_dialog(self, title: str, text: str, default_value: str = "") -> str: + """Create custom input dialog (with icon and dark title bar)""" + dialog = ctk.CTkToplevel(self) + dialog.title(title) + dialog.geometry(DIALOG_INPUT_SIZE) + dialog.resizable(False, False) + + # 设置图标路径 + icon_path = get_icon_path() + + # 第一次设置图标 + if os.path.exists(icon_path): + try: + dialog.iconbitmap(icon_path) + dialog.wm_iconbitmap(icon_path) + except: + pass + + # 设置为模态对话框 + dialog.transient(self) + dialog.grab_set() + + # 居中显示 + dialog.update_idletasks() + x = (dialog.winfo_screenwidth() // 2) - (400 // 2) + y = (dialog.winfo_screenheight() // 2) - (220 // 2) + dialog.geometry(f"{DIALOG_INPUT_SIZE}+{x}+{y}") + + # 设置深色标题栏和图标的组合函数 + def apply_title_bar_and_icon(): + # 先设置深色标题栏 + self._set_dark_title_bar(dialog) + # 再次设置图标(确保不被覆盖) + if os.path.exists(icon_path): + try: + dialog.iconbitmap(icon_path) + dialog.wm_iconbitmap(icon_path) + except: + pass + + # 延迟执行 + dialog.after(10, apply_title_bar_and_icon) + # 再次确保图标设置(多次尝试) + dialog.after(100, lambda: dialog.iconbitmap(icon_path) if os.path.exists(icon_path) else None) + dialog.after(200, lambda: dialog.iconbitmap(icon_path) if os.path.exists(icon_path) else None) + + # 存储结果 + result = {"value": None} + + # 提示文本 + label = ctk.CTkLabel(dialog, text=text, font=ctk.CTkFont(size=13)) + label.pack(pady=(30, 10), padx=20) + + # 输入框 + entry = ctk.CTkEntry(dialog, width=350, height=35) + entry.pack(pady=10, padx=20) + if default_value: + entry.insert(0, default_value) + entry.focus_set() + + # 按钮容器 + btn_frame = ctk.CTkFrame(dialog, fg_color="transparent") + btn_frame.pack(pady=20) + + def on_ok(): + result["value"] = entry.get() + dialog.destroy() + + def on_cancel(): + result["value"] = None + dialog.destroy() + + # OK 按钮 + ok_btn = ctk.CTkButton( + btn_frame, + text="确定", + command=on_ok, + width=150, + height=50, + font=ctk.CTkFont(size=14) + ) + ok_btn.pack(side="left", padx=10) + + # Cancel 按钮 + cancel_btn = ctk.CTkButton( + btn_frame, + text="取消", + command=on_cancel, + width=150, + height=50, + font=ctk.CTkFont(size=14), + fg_color=BUTTON_GRAY, + hover_color=BUTTON_GRAY_HOVER + ) + cancel_btn.pack(side="left", padx=10) + + # 绑定回车键 + entry.bind("", lambda e: on_ok()) + entry.bind("", lambda e: on_cancel()) + + # 等待对话框关闭 + dialog.wait_window() + + return result["value"] + + def _create_custom_confirm_dialog(self, title: str, message: str) -> bool: + """创建自定义确认对话框(带图标和深色标题栏)""" + dialog = ctk.CTkToplevel(self) + dialog.title(title) + dialog.geometry(DIALOG_CONFIRM_SIZE) + dialog.resizable(False, False) + + # 设置图标路径 + icon_path = get_icon_path() + + # 第一次设置图标 + if os.path.exists(icon_path): + try: + dialog.iconbitmap(icon_path) + dialog.wm_iconbitmap(icon_path) + except: + pass + + # 设置为模态对话框 + dialog.transient(self) + dialog.grab_set() + + # 居中显示 + dialog.update_idletasks() + x = (dialog.winfo_screenwidth() // 2) - (450 // 2) + y = (dialog.winfo_screenheight() // 2) - (200 // 2) + dialog.geometry(f"{DIALOG_CONFIRM_SIZE}+{x}+{y}") + + # 设置深色标题栏和图标 + def apply_title_bar_and_icon(): + self._set_dark_title_bar(dialog) + if os.path.exists(icon_path): + try: + dialog.iconbitmap(icon_path) + dialog.wm_iconbitmap(icon_path) + except: + pass + + dialog.after(10, apply_title_bar_and_icon) + dialog.after(100, lambda: dialog.iconbitmap(icon_path) if os.path.exists(icon_path) else None) + dialog.after(200, lambda: dialog.iconbitmap(icon_path) if os.path.exists(icon_path) else None) + + # 存储结果 + result = {"value": False} + + # 消息文本 + label = ctk.CTkLabel( + dialog, + text=message, + font=ctk.CTkFont(size=13), + wraplength=400 + ) + label.pack(pady=(40, 20), padx=20) + + # 按钮容器 + btn_frame = ctk.CTkFrame(dialog, fg_color="transparent") + btn_frame.pack(pady=20) + + def on_yes(): + result["value"] = True + dialog.destroy() + + def on_no(): + result["value"] = False + dialog.destroy() + + # 是 按钮 + yes_btn = ctk.CTkButton( + btn_frame, + text="是(Y)", + command=on_yes, + width=150, + height=50, + font=ctk.CTkFont(size=14), + fg_color=BUTTON_RED, + hover_color=BUTTON_RED_HOVER + ) + yes_btn.pack(side="left", padx=10) + + # 否 按钮 + no_btn = ctk.CTkButton( + btn_frame, + text="否(N)", + command=on_no, + width=150, + height=50, + font=ctk.CTkFont(size=14), + fg_color=BUTTON_GRAY, + hover_color=BUTTON_GRAY_HOVER + ) + no_btn.pack(side="left", padx=10) + + # 绑定键盘快捷键 + dialog.bind("y", lambda e: on_yes()) + dialog.bind("Y", lambda e: on_yes()) + dialog.bind("n", lambda e: on_no()) + dialog.bind("N", lambda e: on_no()) + dialog.bind("", lambda e: on_no()) + + # 等待对话框关闭 + dialog.wait_window() + + return result["value"] + + def _create_widgets(self): + """创建界面组件""" + # 主容器 + self.grid_columnconfigure(0, weight=1) + self.grid_rowconfigure(1, weight=1) + + # 标题 + title_label = ctk.CTkLabel( + self, + text="配置管理", + font=ctk.CTkFont(size=20, weight="bold") + ) + title_label.grid(row=0, column=0, padx=20, pady=(20, 10), sticky="w") + + # 主内容区域 + content_frame = ctk.CTkFrame(self) + content_frame.grid(row=1, column=0, padx=20, pady=(0, 20), sticky="nsew") + content_frame.grid_columnconfigure(0, weight=1) + content_frame.grid_rowconfigure(1, weight=1) + + # 项目选择区域 + project_frame = ctk.CTkFrame(content_frame) + project_frame.grid(row=0, column=0, padx=10, pady=(10, 0), sticky="ew") + project_frame.grid_columnconfigure(1, weight=1) + + # 第一行:项目选择 + ctk.CTkLabel( + project_frame, + text="选择项目:", + font=ctk.CTkFont(size=14, weight="bold") + ).grid(row=0, column=0, padx=10, pady=(10, 5), sticky="w") + + self.project_combo = ctk.CTkComboBox( + project_frame, + command=self._on_project_changed, + width=200, + state="readonly" # 设置为只读,禁止输入 + ) + self.project_combo.grid(row=0, column=1, padx=10, pady=(10, 5), sticky="ew") + + # 第二行:项目管理按钮 + project_buttons_frame = ctk.CTkFrame(project_frame, fg_color="transparent") + project_buttons_frame.grid(row=1, column=0, columnspan=2, padx=10, pady=(5, 5), sticky="ew") + + # 项目管理按钮 + project_buttons = [ + ("新建项目", self._add_project, BUTTON_BLUE, BUTTON_BLUE_HOVER), + ("复制项目", self._copy_project, BUTTON_BLUE, BUTTON_BLUE_HOVER), + ("重命名项目", self._rename_project, BUTTON_BLUE, BUTTON_BLUE_HOVER), + ("删除项目", self._delete_project, BUTTON_RED, BUTTON_RED_HOVER) + ] + + for text, command, fg_color, hover_color in project_buttons: + self._create_button( + project_buttons_frame, text, command, + fg_color=fg_color, hover_color=hover_color + ).pack(side="left", padx=5) + + # 第三行:项目图标和颜色设置 + settings_frame = ctk.CTkFrame(project_frame, fg_color="transparent") + settings_frame.grid(row=2, column=0, columnspan=2, padx=10, pady=(5, 10), sticky="ew") + + # 项目设置按钮 + settings_buttons = [ + ("设置图标", self._set_project_icon), + ("设置背景颜色", self._set_project_color) + ] + + for text, command in settings_buttons: + self._create_button(settings_frame, text, command).pack(side="left", padx=5) + + # 应用列表区域 - 直接在框架内显示标题 + apps_frame = ctk.CTkFrame(content_frame, fg_color="transparent") + apps_frame.grid(row=1, column=0, padx=10, pady=(0, 0), sticky="nsew") + apps_frame.grid_columnconfigure(0, weight=1) + apps_frame.grid_rowconfigure(1, weight=1) + + apps_label = ctk.CTkLabel( + apps_frame, + text="应用列表:", + font=ctk.CTkFont(size=14, weight="bold") + ) + apps_label.grid(row=0, column=0, padx=0, pady=(0, 0), sticky="nw") + + # 滚动框架 - 底部留出空间以显示拖动指示器 + self.apps_scroll_frame = ctk.CTkScrollableFrame(apps_frame) + + self.apps_scroll_frame.grid(row=1, column=0, padx=0, pady=(0, 20), sticky="nsew") + + # 延迟设置滚动条颜色,确保滚动条已创建 + def set_scrollbar_colors(): + try: + # 尝试不同的滚动条属性名称 + for attr_name in ['_scrollbar', '_parent_canvas', '_scrollbar_vertical']: + if hasattr(self.apps_scroll_frame, attr_name): + scrollbar = getattr(self.apps_scroll_frame, attr_name) + if scrollbar and hasattr(scrollbar, 'configure'): + scrollbar.configure( + button_color=SCROLLBAR_COLOR, + button_hover_color=SCROLLBAR_HOVER_COLOR, + fg_color=SCROLLBAR_COLOR + ) + self._log(f"Successfully set scrollbar color via {attr_name}", "DEBUG") + break + except Exception as e: + self._log(f"Failed to set scrollbar color: {e}", "DEBUG") + + # 延迟执行 + self.after(100, set_scrollbar_colors) + + # 在空白区域右键点击时显示粘贴菜单 + self.apps_scroll_frame.bind("", self._show_empty_area_menu) + + # 在空白区域左键点击时取消选择 + self.apps_scroll_frame.bind("", self._handle_empty_area_click) + + # 添加应用按钮 + add_app_btn = ctk.CTkButton( + content_frame, + text="+ 添加应用", + command=self._add_app, + height=40, + font=ctk.CTkFont(size=14, weight="bold"), + fg_color=BUTTON_GREEN, + hover_color=BUTTON_GREEN_HOVER + ) + add_app_btn.grid(row=2, column=0, padx=10, pady=(5, 10), sticky="ew") + + def _load_data(self): + """加载数据""" + # 加载项目列表 + projects = self.config_manager.get_projects() + if projects: + self.project_combo.configure(values=projects) + current_project = self.config_manager.get_current_project() + if current_project in projects: + self.project_combo.set(current_project) + else: + self.project_combo.set(projects[0]) + self._load_apps() + else: + self.project_combo.configure(values=[""]) + self.project_combo.set("") + + def _on_project_changed(self, choice): + """项目切换事件""" + self._load_apps() + + def _load_apps(self): + """加载应用列表""" + # 清空选择 + self.selected_items = [] + self.last_selected_index = None + + # 清空现有应用 + for widget in self.apps_scroll_frame.winfo_children(): + widget.destroy() + + current_project = self.project_combo.get() + if not current_project: + return + + apps = self.config_manager.get_apps(current_project) + + for idx, app in enumerate(apps): + self._create_app_item(idx, app) + + def _create_app_item(self, index: int, app: dict): + """创建应用项""" + item_frame = ctk.CTkFrame( + self.apps_scroll_frame, + corner_radius=12, + fg_color=SCROLLBAR_COLOR # 设置与滑块一致的背景色 + ) + item_frame.pack(fill="x", padx=5, pady=3) + item_frame.grid_columnconfigure(1, weight=1) + + # 存储索引信息 + item_frame.app_index = index + + # 设置应用项边框 + item_frame.configure(border_width=1, border_color=BORDER_COLOR) + + # 创建一个容器框架来定位拖动手柄,覆盖整个卡片高度 + handle_container = ctk.CTkFrame(item_frame, fg_color="transparent") + handle_container.grid(row=0, column=0, rowspan=4, padx=(5, 8), pady=8, sticky="ns") # 增加rowspan为4覆盖按钮行 + handle_container.grid_rowconfigure(0, weight=1) # 上方留出空间 + handle_container.grid_rowconfigure(1, weight=0) # 手柄行 + handle_container.grid_rowconfigure(2, weight=1) # 下方留出空间 + + # 添加拖放手柄 - 使用更协调的设计并垂直居中 + # 创建一个背景框作为手柄的容器 + drag_handle_bg = ctk.CTkFrame( + handle_container, + width=30, + height=100, + fg_color="transparent", # 透明背景,使用Canvas绘制 + corner_radius=15 # 增大圆角 + ) + drag_handle_bg.grid(row=1, column=0, sticky="ns") + drag_handle_bg.grid_propagate(False) # 防止内容影响容器大小 + drag_handle_bg.grid_rowconfigure(0, weight=1) + drag_handle_bg.grid_columnconfigure(0, weight=1) + + # 使用Canvas而不是Label来显示拖动符号,以解决偏移问题 + drag_handle = ctk.CTkCanvas( + drag_handle_bg, + width=30, + height=100, + bg=SCROLLBAR_COLOR, # 与滚动条颜色统一 + highlightthickness=0, # 移除边框 + cursor="hand2" + ) + drag_handle.grid(row=0, column=0, sticky="nsew") + + # 在Canvas中心绘制三条水平线 + line_color = LINE_COLOR_GRAY + line_width = 18 # 线条长度 + line_height = 3 # 线条高度 + line_spacing = 7 # 线条间距 + + # 计算中心位置 + center_x = 15 + center_y = 50 + + # 计算第一条线的位置(三条线的中心位置) + start_y = center_y - line_spacing + + # 绘制三条线并保存线条ID + line_ids = [] + for i in range(3): + y_pos = start_y + i * line_spacing + line_id = drag_handle.create_line( + center_x - line_width/2, y_pos, + center_x + line_width/2, y_pos, + fill=line_color, + width=line_height, + capstyle="round" # 圆形线条端点 + ) + line_ids.append(line_id) + + # 在item_frame上保存拖动手柄的引用,用于后续更新颜色 + item_frame.drag_handle = drag_handle + item_frame.drag_handle_line_ids = line_ids + + # 绑定拖放事件到手柄文本 + drag_handle.bind("", lambda e, idx=index, frame=item_frame: self._start_drag(e, idx, frame)) + drag_handle.bind("", self._on_drag_motion) + drag_handle.bind("", self._end_drag) + + # 绑定左键点击和拖动事件到卡片本身 + item_frame.bind("", lambda e, idx=index, frame=item_frame: self._on_item_click_or_drag_start(e, idx, frame)) + item_frame.bind("", lambda e, idx=index, frame=item_frame: self._on_item_drag_motion(e, idx, frame)) + item_frame.bind("", self._on_item_drag_end) + + # 绑定右键菜单(不阻止事件传播,这样空白区域也能触发) + item_frame.bind("", lambda e, idx=index: self._show_context_menu(e, idx), add="+") + + # 绑定拖放事件到手柄背景框 + drag_handle_bg.bind("", lambda e, idx=index, frame=item_frame: self._start_drag(e, idx, frame)) + drag_handle_bg.bind("", self._on_drag_motion) + drag_handle_bg.bind("", self._end_drag) + + # 同时绑定拖放事件到手柄外层容器 + handle_container.bind("", lambda e, idx=index, frame=item_frame: self._start_drag(e, idx, frame)) + handle_container.bind("", self._on_drag_motion) + handle_container.bind("", self._end_drag) + + # 应用名称 + name_label = ctk.CTkLabel( + item_frame, + text=f"名称: {app['name']}", + font=ctk.CTkFont(size=12, weight="bold") + ) + name_label.grid(row=0, column=1, columnspan=3, padx=10, pady=(8, 2), sticky="w") + name_label.bind("", lambda e, idx=index: self._show_context_menu(e, idx)) + + # 路径 + path_label = ctk.CTkLabel( + item_frame, + text=f"路径: {app['path']}", + font=ctk.CTkFont(size=11) + ) + path_label.grid(row=1, column=1, columnspan=3, padx=10, pady=1, sticky="w") + path_label.bind("", lambda e, idx=index: self._show_context_menu(e, idx)) + + # 版本 + version_label = ctk.CTkLabel( + item_frame, + text=f"版本: {app['version']}", + font=ctk.CTkFont(size=11) + ) + version_label.grid(row=2, column=1, columnspan=3, padx=10, pady=(1, 6), sticky="w") + version_label.bind("", lambda e, idx=index: self._show_context_menu(e, idx)) + + # 按钮行容器 + button_frame = ctk.CTkFrame(item_frame, fg_color="transparent") + button_frame.grid(row=3, column=1, columnspan=3, padx=10, pady=(0, 8), sticky="e") + + # 编辑按钮 + ctk.CTkButton( + button_frame, + text="编辑", + command=lambda: self._edit_app(index, app), + width=80, + font=ctk.CTkFont(size=12), + fg_color=BUTTON_BLUE, + hover_color=BUTTON_BLUE_HOVER + ).pack(side="left", padx=5) + + # 删除按钮 + ctk.CTkButton( + button_frame, + text="删除", + command=lambda: self._delete_app(index), + width=80, + font=ctk.CTkFont(size=12), + fg_color=BUTTON_RED, + hover_color=BUTTON_RED_HOVER + ).pack(side="left", padx=5) + + def _add_project(self): + """添加项目""" + # 使用自定义输入对话框 + project_name = self._create_custom_input_dialog( + title="新建项目", + text="请输入项目名称:" + ) + + if project_name: + # 设置默认图标为 NexusLauncher.ico(只保存文件名,不保存完整路径) + default_icon = "NexusLauncher.ico" + if self.config_manager.add_project(project_name, default_icon): + # 先通知主窗口更新(刷新项目列表) + self.on_update() + # 再刷新设置窗口的数据 + self._load_data() + # 自动切换到新建的项目 + self.project_combo.set(project_name) + self._load_apps() # 刷新应用列表 + + # 检查新项目的应用数量 + apps = self.config_manager.get_apps(project_name) + + # 确保键盘事件在新项目创建后仍然有效 + self.focus_set() + self.focus_force() + + # 显示成功消息 + custom_dialogs.show_info(self, "成功", f"项目 '{project_name}' 已创建") + else: + custom_dialogs.show_error(self, "错误", "项目已存在或创建失败") + + def _delete_project(self): + """删除项目""" + current_project = self._ensure_project_selected() + if not current_project: + return + + # 使用自定义确认对话框 + result = self._create_custom_confirm_dialog( + title="确认删除", + message=f"确定要删除项目 '{current_project}' 吗?\n这将删除该项目下的所有应用配置。" + ) + + if result: + if self.config_manager.delete_project(current_project): + custom_dialogs.show_info(self, "成功", "项目删除成功") + self._load_data() + self.on_update() + else: + custom_dialogs.show_error(self, "错误", "删除项目失败") + + def _rename_project(self): + """重命名项目""" + current_project = self._ensure_project_selected() + if not current_project: + return + + # 使用自定义输入对话框 + new_name = self._create_custom_input_dialog( + title="重命名项目", + text=f"请输入项目 '{current_project}' 的新名称:", + default_value=current_project + ) + + if new_name: + if self.config_manager.rename_project(current_project, new_name): + # 先通知主窗口更新(刷新项目列表) + self.on_update() + # 再刷新设置窗口的数据 + self._load_data() + # 自动切换到重命名后的项目 + self.project_combo.set(new_name) + self._load_apps() # 刷新应用列表 + # 显示成功消息 + custom_dialogs.show_info(self, "成功", f"项目重命名为 '{new_name}'") + else: + custom_dialogs.show_error(self, "错误", "重命名项目失败,可能名称重复") + elif new_name == current_project: + custom_dialogs.show_info(self, "成功", "新名称与原名称相同,无需修改") + + def _copy_project(self): + """复制项目""" + current_project = self._ensure_project_selected() + if not current_project: + return + + # 生成默认新项目名称(自动添加 _01, _02 等后缀) + base_name = current_project + counter = 1 + default_name = f"{base_name}_01" + + # 检查名称是否已存在,如果存在则递增数字 + existing_projects = self.config_manager.get_projects() + while default_name in existing_projects: + counter += 1 + default_name = f"{base_name}_{counter:02d}" + + # 弹出对话框让用户输入新项目名称 + new_project_name = custom_dialogs.ask_string( + self, + "复制项目", + f"请输入新项目名称:", + default_name + ) + + if not new_project_name: + return # 用户取消 + + if new_project_name in existing_projects: + custom_dialogs.show_error(self, "错误", "项目名称已存在,请使用其他名称") + return + + # 创建新项目(设置默认图标,只保存文件名) + default_icon = "NexusLauncher.ico" + if self.config_manager.add_project(new_project_name, default_icon): + # 复制源项目的图标和颜色(如果有) + source_icon = self.config_manager.get_project_icon(current_project) + if source_icon: + self.config_manager.set_project_icon(new_project_name, source_icon) + + source_color = self.config_manager.get_project_color(current_project) + if source_color: + self.config_manager.set_project_color(new_project_name, source_color) + + # 获取源项目的所有应用 + source_apps = self.config_manager.get_apps(current_project) + + # 复制所有应用到新项目 + for app in source_apps: + # 添加应用 + self.config_manager.add_app( + new_project_name, + app['name'], + app['path'], + app['version'] + ) + + # 复制图标设置 + icon = self.config_manager.get_app_icon(app['path']) + if icon: + self.config_manager.set_app_icon(app['path'], icon) + + # 复制颜色设置 + color = self.config_manager.get_app_color(app['path']) + if color: + self.config_manager.set_app_color(app['path'], color) + + # 复制 task_settings(直接从 config_data 复制原始数据,避免格式转换) + if (current_project in self.config_manager.config_data.get("projects", {}) and + "task_settings" in self.config_manager.config_data["projects"][current_project]): + + # 直接复制原始的 task_settings(保持 JSON 中的正斜杠格式) + source_task_settings = self.config_manager.config_data["projects"][current_project]["task_settings"] + + # 确保新项目结构存在 + if not self.config_manager.config_data.get("projects"): + self.config_manager.config_data["projects"] = {} + if new_project_name not in self.config_manager.config_data["projects"]: + self.config_manager.config_data["projects"][new_project_name] = {} + + # 深拷贝 task_settings 到新项目 + import copy + self.config_manager.config_data["projects"][new_project_name]["task_settings"] = copy.deepcopy(source_task_settings) + + # 保存配置 + self.config_manager.save_config() + + # 先通知主窗口更新(刷新项目列表) + self.on_update() + # 再刷新设置窗口的数据 + self._load_data() + # 自动切换到新复制的项目 + self.project_combo.set(new_project_name) + self._load_apps() # 刷新应用列表 + # 显示成功消息 + custom_dialogs.show_info( + self, + "成功", + f"已复制项目 '{current_project}' 到 '{new_project_name}'\n共复制了 {len(source_apps)} 个应用" + ) + else: + custom_dialogs.show_error(self, "错误", "复制项目失败") + + def _add_app(self): + """添加应用""" + current_project = self._ensure_project_selected() + if not current_project: + return + + self._show_app_dialog(current_project) + + def _edit_app(self, index: int, app: dict): + """编辑应用""" + current_project = self.project_combo.get() + if not current_project: + return + + self._show_app_dialog(current_project, index, app) + + def _delete_app(self, index: int): + """删除应用""" + current_project = self.project_combo.get() + if not current_project: + return + + result = custom_dialogs.ask_yes_no(self, "确认删除", "确定要删除这个应用吗?") + + if result: + if self.config_manager.delete_app(current_project, index): + custom_dialogs.show_info(self, "成功", "应用已删除") + self._load_apps() + self.on_update() + else: + custom_dialogs.show_error(self, "错误", "删除应用失败") + + def _start_drag(self, event, index, frame): + """开始拖放""" + self.drag_source = index + self.drag_widget = frame + + # 记录鼠标相对于组件的位置 + self.drag_data = (event.x_root, event.y_root, frame.winfo_y()) + + # 改变组件外观,表示正在拖动 + frame.configure(border_width=2, border_color=DRAG_HIGHLIGHT_COLOR, fg_color=DRAG_HIGHLIGHT_BG) + + # 更新拖动手柄的颜色 + if hasattr(frame, 'drag_handle') and hasattr(frame, 'drag_handle_line_ids'): + # 更改Canvas背景色为与卡片一致的高亮色 + frame.drag_handle.configure(bg=DRAG_HIGHLIGHT_BG) + # 更改线条颜色为高亮色 + for line_id in frame.drag_handle_line_ids: + frame.drag_handle.itemconfig(line_id, fill=DRAG_HIGHLIGHT_COLOR) + + # 创建拖动时的光标指示器 + if not hasattr(self, 'insert_indicator') or not self.insert_indicator.winfo_exists(): + self.insert_indicator = ctk.CTkFrame(self.apps_scroll_frame, height=4, fg_color=DRAG_HIGHLIGHT_COLOR, corner_radius=2) + + # 阻止事件传播到item_frame,避免触发多选逻辑 + return "break" + + def _on_drag_motion(self, event): + """拖放过程中""" + if not self.drag_data or not self.drag_widget: + return + + # 计算移动距离 + x_origin, y_origin, y_start = self.drag_data + y_offset = event.y_root - y_origin + new_y = y_start + y_offset + + # 获取所有应用项,排除插入指示器 + all_children = self.apps_scroll_frame.winfo_children() + app_frames = [f for f in all_children if f != getattr(self, 'insert_indicator', None)] + if not app_frames: + return + + # 简化的拖动逻辑 + target_index = self.drag_source + insert_y = 0 + insert_at_end = False + found = False + + # 遍历所有卡片,找到鼠标位置对应的目标 + for i, frame in enumerate(app_frames): + if i == self.drag_source: + continue + + frame_y = frame.winfo_y() + frame_h = frame.winfo_height() + frame_mid = frame_y + frame_h / 2 + + # 如果鼠标在这张卡片的上半部分,插入到它之前 + if new_y < frame_mid: + target_index = i + # 确保指示器不会超出可视区域 + insert_y = max(10, frame_y - 4) + found = True + break + + # 如果没有找到插入位置,说明要插入到末尾 + if not found: + # 找到最后一张非拖动卡片 + last_frame = None + for i in range(len(app_frames) - 1, -1, -1): + if i != self.drag_source: + last_frame = app_frames[i] + break + + if last_frame: + target_index = len(app_frames) + # 将指示器放在最后一张卡片内部的底部,而不是下方 + frame_bottom = last_frame.winfo_y() + last_frame.winfo_height() + # 指示器显示在卡片底部边缘 + insert_y = frame_bottom - 2 + insert_at_end = True + + if target_index != self.drag_target: + self.drag_target = target_index + + # 重置所有应用项的外观 + for i, frame in enumerate(app_frames): + if i != self.drag_source: + frame.configure(border_width=1, border_color=BORDER_COLOR) + + # 显示插入指示器 + try: + indicator_width = self.apps_scroll_frame.winfo_width() - 20 + self.insert_indicator.configure(width=indicator_width) + self.insert_indicator.place(x=10, y=insert_y - 4) + self.insert_indicator.lift() + self.insert_indicator.configure(fg_color=DRAG_HIGHLIGHT_COLOR, border_width=1, border_color=BORDER_COLOR_WHITE) + + except Exception: + pass + + def _end_drag(self, event): + """结束拖放""" + if self.drag_source is not None and self.drag_target is not None and self.drag_source != self.drag_target: + current_project = self.project_combo.get() + if current_project: + # 重新排序应用 + if self.config_manager.reorder_apps(current_project, self.drag_source, self.drag_target): + self._load_apps() + self.on_update() + + # 隐藏插入指示器 + try: + if hasattr(self, 'insert_indicator') and self.insert_indicator.winfo_exists(): + if self.insert_indicator.winfo_ismapped(): + self.insert_indicator.place_forget() + except Exception as e: + self._log(f"Error hiding indicator on end drag: {e}", "DEBUG") + + # 重置所有应用项的外观 + for frame in self.apps_scroll_frame.winfo_children(): + if frame != getattr(self, 'insert_indicator', None): + try: + frame.configure(border_width=1, border_color=BORDER_COLOR, fg_color="transparent") + # 恢复拖动手柄的颜色 + if hasattr(frame, 'drag_handle') and hasattr(frame, 'drag_handle_line_ids'): + frame.drag_handle.configure(bg=SCROLLBAR_COLOR) # 与滚动条统一 + for line_id in frame.drag_handle_line_ids: + frame.drag_handle.itemconfig(line_id, fill=LINE_COLOR_GRAY) + except Exception: + pass + + # 重置拖放状态 + self.drag_source = None + self.drag_target = None + self.drag_data = None + self.drag_widget = None + + def _show_app_dialog(self, project_name: str, index: int = -1, app: dict = None): + """显示应用编辑对话框""" + dialog = ctk.CTkToplevel(self) + dialog.title("编辑应用" if app else "添加应用") + dialog.geometry(DIALOG_APP_EDIT_SIZE) + + # 设置对话框背景颜色(与设置窗口一致) + dialog.configure(fg_color=DIALOG_BG_COLOR) + + # 设置窗口图标(在transient之前) + icon_path = get_icon_path() + if os.path.exists(icon_path): + try: + dialog.iconbitmap(icon_path) + dialog.wm_iconbitmap(icon_path) + except Exception: + pass + + dialog.transient(self) + dialog.grab_set() + + # 设置深色标题栏(Windows 10/11) + dialog.after(10, lambda: self._set_dark_title_bar(dialog)) + + # 居中显示 + dialog.update_idletasks() + x = (dialog.winfo_screenwidth() // 2) - (650 // 2) + y = (dialog.winfo_screenheight() // 2) - (700 // 2) + dialog.geometry(f"{DIALOG_APP_EDIT_SIZE}+{x}+{y}") + + # 再次尝试设置图标(确保生效) + if os.path.exists(icon_path): + def set_icon_delayed(): + try: + dialog.iconbitmap(icon_path) + dialog.wm_iconbitmap(icon_path) + except Exception: + pass + dialog.after(50, set_icon_delayed) + dialog.after(200, set_icon_delayed) + + # 表单 + form_frame = ctk.CTkFrame(dialog) + form_frame.pack(fill="both", expand=True, padx=20, pady=20) + + # 获取图标文件夹路径 + icons_dir = get_icons_dir() + + # 应用名称 + ctk.CTkLabel(form_frame, text="应用名称:", font=ctk.CTkFont(size=12)).pack(anchor="w", pady=(10, 5)) + name_entry = ctk.CTkEntry(form_frame, placeholder_text="例如: 记事本") + name_entry.pack(fill="x", pady=(0, 10)) + if app: + name_entry.insert(0, app['name']) + + # 应用路径 + ctk.CTkLabel(form_frame, text="应用路径:", font=ctk.CTkFont(size=12)).pack(anchor="w", pady=(10, 5)) + path_frame = ctk.CTkFrame(form_frame) + path_frame.pack(fill="x", pady=(0, 10)) + + path_entry = ctk.CTkEntry(path_frame, placeholder_text="例如: C:\\Program Files\\app.exe") + path_entry.pack(side="left", fill="x", expand=True, padx=(0, 10)) + if app: + # 显示时将正斜杠转换为反斜杠(UI 显示格式) + display_path = app['path'].replace("/", "\\") + path_entry.insert(0, display_path) + + def browse_file(): + filename = filedialog.askopenfilename( + title="选择应用程序", + filetypes=[("可执行文件", "*.exe"), ("所有文件", "*.*")] + ) + if filename: + # 将选择的路径标准化为反斜杠(UI 显示格式) + normalized_path = filename.replace("/", "\\") + path_entry.delete(0, "end") + path_entry.insert(0, normalized_path) + + ctk.CTkButton(path_frame, text="浏览", command=browse_file, width=80).pack(side="right") + + # 版本号 + ctk.CTkLabel(form_frame, text="版本号:", font=ctk.CTkFont(size=12)).pack(anchor="w", pady=(10, 5)) + version_entry = ctk.CTkEntry(form_frame, placeholder_text="例如: 1.0.0") + version_entry.pack(fill="x", pady=(0, 10)) + if app: + version_entry.insert(0, app['version']) + + # 图标选择 + ctk.CTkLabel(form_frame, text="选择图标:", font=ctk.CTkFont(size=12)).pack(anchor="w", pady=(10, 5)) + + icon_frame = ctk.CTkFrame(form_frame) + icon_frame.pack(fill="x", pady=(0, 10)) + + # 默认图标路径(使用 NexusLauncher.ico 作为默认图标) + default_icon_path = get_icon_path() + selected_icon = ctk.StringVar(value=default_icon_path if os.path.exists(default_icon_path) else "") + + # 如果已有图标设置,则加载 + if app and app['path']: + custom_icon = self.config_manager.get_app_icon(app['path']) + if custom_icon and os.path.exists(custom_icon): + selected_icon.set(custom_icon) + + # 显示当前选择的图标 + icon_preview_frame = ctk.CTkFrame(icon_frame) + icon_preview_frame.pack(side="left", padx=(0, 10)) + + icon_preview_label = ctk.CTkLabel(icon_preview_frame, text="无图标", image=None) + icon_preview_label.pack(padx=10, pady=10) + + # 更新图标预览 + def update_icon_preview(icon_path=None): + if not icon_path and selected_icon.get(): + icon_path = selected_icon.get() + + if icon_path and os.path.exists(icon_path): + try: + # 加载图标并调整大小 + img = Image.open(icon_path) + img = img.resize((48, 48), Image.Resampling.LANCZOS) + ctk_img = ctk.CTkImage(light_image=img, dark_image=img, size=(48, 48)) + icon_preview_label.configure(image=ctk_img, text="") # 清除文字 + icon_preview_label._image = ctk_img # 保持引用以防止垃圾回收 + except Exception as e: + self._log(f"Failed to load icon preview: {e}", "DEBUG") + icon_preview_label.configure(image=None, text="Preview failed") + else: + icon_preview_label.configure(image=None, text="No icon") + + # 初始化预览 + update_icon_preview() + + # 图标选择按钮 + icon_buttons_frame = ctk.CTkFrame(icon_frame, fg_color="transparent") + icon_buttons_frame.pack(side="left", fill="x", expand=True) + + def browse_icon(): + filename = filedialog.askopenfilename( + title="选择图标文件", + filetypes=[("图标文件", "*.png;*.ico;*.jpg;*.jpeg"), ("所有文件", "*.*")] + ) + if filename: + selected_icon.set(filename) + update_icon_preview(filename) + + def select_preset_icon(): + # 创建预设图标选择对话框 + icon_dialog = ctk.CTkToplevel(dialog) + icon_dialog.title("选择预设图标") + icon_dialog.geometry(DIALOG_ICON_SELECT_SIZE) + + # 设置窗口图标(在transient之前) + icon_path = get_icon_path() + if os.path.exists(icon_path): + try: + icon_dialog.iconbitmap(icon_path) + icon_dialog.wm_iconbitmap(icon_path) + except Exception: + pass + + icon_dialog.transient(dialog) + icon_dialog.grab_set() + + # 再次尝试设置图标(确保生效) + if os.path.exists(icon_path): + def set_icon_delayed(): + try: + icon_dialog.iconbitmap(icon_path) + icon_dialog.wm_iconbitmap(icon_path) + except Exception: + pass + icon_dialog.after(50, set_icon_delayed) + icon_dialog.after(200, set_icon_delayed) + + # 获取所有预设图标 + preset_icons = glob.glob(os.path.join(icons_dir, "*.png")) + preset_icons.extend(glob.glob(os.path.join(icons_dir, "*.ico"))) + + # 创建滚动区域 + scroll_frame = ctk.CTkScrollableFrame( + icon_dialog, + scrollbar_button_color=SCROLLBAR_COLOR, + scrollbar_button_hover_color=SCROLLBAR_HOVER_COLOR + ) + scroll_frame.pack(fill="both", expand=True, padx=20, pady=20) + + # 创建网格布局 + row, col = 0, 0 + max_cols = 5 + + for icon_path in preset_icons: + try: + # 创建图标项 + icon_item = ctk.CTkFrame(scroll_frame) + icon_item.grid(row=row, column=col, padx=5, pady=5) + + # 加载图标 + img = Image.open(icon_path) + img = img.resize((48, 48), Image.Resampling.LANCZOS) + ctk_img = ctk.CTkImage(light_image=img, dark_image=img, size=(48, 48)) + + # 创建按钮 + def make_select_cmd(path): + return lambda: select_icon(path) + + icon_btn = ctk.CTkButton( + icon_item, + text="", + image=ctk_img, + command=make_select_cmd(icon_path), + width=60, + height=60 + ) + icon_btn.pack(padx=5, pady=5) + icon_btn._image = ctk_img # 保持引用 + + # 显示图标名称 + icon_name = os.path.splitext(os.path.basename(icon_path))[0] + ctk.CTkLabel( + icon_item, + text=icon_name, + font=ctk.CTkFont(size=10) + ).pack(pady=(0, 5)) + + # 更新行列位置 + col += 1 + if col >= max_cols: + col = 0 + row += 1 + except Exception as e: + self._log(f"Failed to load preset icon {icon_path}: {e}", "DEBUG") + + def select_icon(path): + selected_icon.set(path) + update_icon_preview(path) + icon_dialog.destroy() + + # 取消按钮 + ctk.CTkButton( + icon_dialog, + text="取消", + command=icon_dialog.destroy, + width=100 + ).pack(pady=10) + + # 图标选择按钮 + ctk.CTkButton( + icon_buttons_frame, + text="选择预设图标", + command=select_preset_icon + ).pack(side="left", padx=5, pady=5) + + ctk.CTkButton( + icon_buttons_frame, + text="浏览本地图标", + command=browse_icon + ).pack(side="left", padx=5, pady=5) + + ctk.CTkButton( + icon_buttons_frame, + text="清除图标", + command=lambda: [selected_icon.set(""), update_icon_preview()] + ).pack(side="left", padx=5, pady=5) + + # 按钮颜色选择 + ctk.CTkLabel(form_frame, text="按钮颜色:", font=ctk.CTkFont(size=12)).pack(anchor="w", pady=(10, 5)) + + color_frame = ctk.CTkFrame(form_frame) + color_frame.pack(fill="x", pady=(0, 10)) + + # 默认颜色(使用预设颜色列表中的第一个) + default_color = PRESET_COLORS[0] # 蓝灰色 + selected_color = ctk.StringVar(value=default_color) + + # 如果已有颜色设置,则加载 + if app and app['path']: + custom_color = self.config_manager.get_app_color(app['path']) + if custom_color: + selected_color.set(custom_color) + + # 颜色预览框 + color_preview_frame = ctk.CTkFrame(color_frame, width=60, height=60, corner_radius=10) + color_preview_frame.pack(side="left", padx=(0, 10)) + color_preview_frame.pack_propagate(False) + + color_preview_label = ctk.CTkLabel(color_preview_frame, text="", width=60, height=60, corner_radius=10) + color_preview_label.pack(fill="both", expand=True) + + # 更新颜色预览 + def update_color_preview(color=None): + if not color and selected_color.get(): + color = selected_color.get() + + if color: + color_preview_label.configure(fg_color=color, text="") + else: + color_preview_label.configure(fg_color="transparent", text="无颜色") + + # 初始化预览 + update_color_preview() + + # 使用配置文件中的预设颜色列表 + preset_colors = PRESET_COLORS + + # 颜色选择按钮容器 + color_buttons_frame = ctk.CTkFrame(color_frame, fg_color="transparent") + color_buttons_frame.pack(side="left", fill="x", expand=True) + + # 创建颜色按钮网格 + color_grid_frame = ctk.CTkFrame(color_buttons_frame, fg_color="transparent") + color_grid_frame.pack(fill="x", pady=5) + + for idx, color in enumerate(preset_colors): + row = idx // 6 + col = idx % 6 + + def make_color_cmd(c): + return lambda: [selected_color.set(c), update_color_preview(c)] + + color_btn = ctk.CTkButton( + color_grid_frame, + text="", + width=35, + height=35, + fg_color=color, + hover_color=color, + command=make_color_cmd(color), + corner_radius=8 + ) + color_btn.grid(row=row, column=col, padx=3, pady=3) + + # 自定义颜色和清除按钮 + custom_color_frame = ctk.CTkFrame(color_buttons_frame, fg_color="transparent") + custom_color_frame.pack(fill="x", pady=5) + + def choose_custom_color(): + from tkinter import colorchooser + color = colorchooser.askcolor(title="选择颜色") + if color and color[1]: + selected_color.set(color[1]) + update_color_preview(color[1]) + + ctk.CTkButton( + custom_color_frame, + text="自定义颜色", + command=choose_custom_color, + width=120 + ).pack(side="left", padx=5) + + ctk.CTkButton( + custom_color_frame, + text="清除颜色", + command=lambda: [selected_color.set(""), update_color_preview()], + width=100 + ).pack(side="left", padx=5) + + # 按钮 + btn_frame = ctk.CTkFrame(form_frame) + btn_frame.pack(fill="x", pady=(10, 0)) + + def save_app(): + name = name_entry.get().strip() + path = path_entry.get().strip() + version = version_entry.get().strip() + + if not name or not path or not version: + custom_dialogs.show_warning(dialog, "警告", "请填写所有字段") + return + + # 将路径转换为正斜杠格式(JSON 存储格式) + path = path.replace("\\", "/") + + # 保存图标设置 + icon_path = selected_icon.get() + # 保存颜色设置 + color = selected_color.get() + + if index >= 0: + # 更新 + if self.config_manager.update_app(project_name, index, name, path, version): + # 设置图标 + if icon_path: + self.config_manager.set_app_icon(path, icon_path) + else: + self.config_manager.remove_app_icon(path) + + # 设置颜色 + if color: + self.config_manager.set_app_color(path, color) + else: + self.config_manager.remove_app_color(path) + + custom_dialogs.show_info(dialog, "成功", "应用已更新") + dialog.destroy() + self._load_apps() + self.on_update() + else: + custom_dialogs.show_error(dialog, "错误", "更新应用失败") + else: + # 添加 + if self.config_manager.add_app(project_name, name, path, version): + # 设置图标 + if icon_path: + self.config_manager.set_app_icon(path, icon_path) + + # 设置颜色 + if color: + self.config_manager.set_app_color(path, color) + + custom_dialogs.show_info(dialog, "成功", "应用已添加") + dialog.destroy() + self._load_apps() + self.on_update() + else: + custom_dialogs.show_error(dialog, "错误", "添加应用失败") + + ctk.CTkButton( + btn_frame, + text="保存", + command=save_app, + fg_color=SAVE_BUTTON_COLOR, + hover_color=SAVE_BUTTON_HOVER + ).pack(side="right", padx=5) + + ctk.CTkButton( + btn_frame, + text="取消", + command=dialog.destroy, + fg_color=BUTTON_GRAY, + hover_color=BUTTON_GRAY_HOVER + ).pack(side="right", padx=5) + + def _on_item_click_or_drag_start(self, event, index: int, frame): + """处理卡片点击或拖动开始(智能判断)""" + # 记录初始点击位置和时间,用于判断是点击还是拖动 + self.click_start_pos = (event.x_root, event.y_root) + self.click_start_time = event.time + self.potential_drag_index = index + self.potential_drag_frame = frame + self.is_dragging = False + + # 如果点击的是已选中的卡片,暂不处理选择逻辑,等待判断是拖动还是点击 + if index in self.selected_items: + self.pending_selection = None + else: + # 如果点击的是未选中的卡片,记录待处理的选择操作 + self.pending_selection = (event, index, frame) + + def _on_item_drag_motion(self, event, index: int, frame): + """处理卡片拖动移动""" + if not hasattr(self, 'click_start_pos'): + return + + # 计算移动距离 + dx = abs(event.x_root - self.click_start_pos[0]) + dy = abs(event.y_root - self.click_start_pos[1]) + + # 如果移动距离超过阈值(5像素),认为是拖动操作 + if dx > 5 or dy > 5: + if not self.is_dragging: + # 开始拖动 + self.is_dragging = True + + # 如果拖动的是未选中的卡片,先选中它 + if hasattr(self, 'pending_selection') and self.pending_selection: + evt, idx, frm = self.pending_selection + self._on_item_click(evt, idx, frm) + self.pending_selection = None + + # 开始拖动选中的卡片 + self._start_drag(event, self.potential_drag_index, self.potential_drag_frame) + else: + # 继续拖动 + self._on_drag_motion(event) + + def _on_item_drag_end(self, event): + """处理卡片拖动结束""" + if hasattr(self, 'is_dragging') and self.is_dragging: + # 结束拖动 + self._end_drag(event) + self.is_dragging = False + elif hasattr(self, 'pending_selection') and self.pending_selection: + # 没有发生拖动,执行点击选择 + evt, idx, frm = self.pending_selection + self._on_item_click(evt, idx, frm) + self.pending_selection = None + + # 清理临时变量 + if hasattr(self, 'click_start_pos'): + delattr(self, 'click_start_pos') + if hasattr(self, 'potential_drag_index'): + delattr(self, 'potential_drag_index') + if hasattr(self, 'potential_drag_frame'): + delattr(self, 'potential_drag_frame') + + def _on_item_click(self, event, index: int, frame): + """处理应用项点击事件(用于多选)""" + # 检查是否按住Shift键 + if event.state & 0x0001: # Shift键 + if self.last_selected_index is not None: + # Shift多选:选择从上次选中到当前点击之间的所有项 + start = min(self.last_selected_index, index) + end = max(self.last_selected_index, index) + self.selected_items = list(range(start, end + 1)) + else: + # 如果没有上次选中的项,只选中当前项 + self.selected_items = [index] + elif event.state & 0x0004: # Ctrl键 + # Ctrl多选:切换当前项的选中状态 + if index in self.selected_items: + self.selected_items.remove(index) + else: + self.selected_items.append(index) + else: + # 普通点击:只选中当前项 + self.selected_items = [index] + + self.last_selected_index = index + self._update_selection_display() + + # 阻止事件冒泡到空白区域处理 + return "break" + + def _update_selection_display(self): + """更新选中项的显示效果(优化版)""" + # 取消之前的延迟更新 + if hasattr(self, '_update_timer') and self._update_timer is not None: + self.after_cancel(self._update_timer) + + # 延迟更新以减少频繁刷新 + self._update_timer = self.after(10, self._do_update_selection_display) + + def _do_update_selection_display(self): + """执行选择显示更新""" + selected_set = set(self.selected_items) # 使用集合提高查找效率 + + # 遍历所有应用项,更新选中状态 + for widget in self.apps_scroll_frame.winfo_children(): + if hasattr(widget, 'app_index'): + is_selected = widget.app_index in selected_set + + if is_selected: + # 选中状态 - 使用边框和背景色高亮 + widget.configure(border_width=2, border_color=SELECTION_BORDER, fg_color=SELECTION_BG) + # 更新拖动手柄颜色为选中状态 + if hasattr(widget, 'drag_handle') and hasattr(widget, 'drag_handle_line_ids'): + for line_id in widget.drag_handle_line_ids: + widget.drag_handle.itemconfig(line_id, fill=SELECTION_BORDER) + # 手柄背景与选中背景一致 + widget.drag_handle.configure(bg=SELECTION_BG) + else: + # 未选中状态 - 使用与滑块一致的背景色 + widget.configure(border_width=1, border_color=BORDER_COLOR, fg_color=SCROLLBAR_COLOR) + # 恢复拖动手柄颜色为默认状态 + if hasattr(widget, 'drag_handle') and hasattr(widget, 'drag_handle_line_ids'): + for line_id in widget.drag_handle_line_ids: + widget.drag_handle.itemconfig(line_id, fill=LINE_COLOR_GRAY) + widget.drag_handle.configure(bg=SCROLLBAR_COLOR) # 与滚动条统一 + + def _handle_empty_area_click(self, event): + """处理滚动框架内空白区域左键点击,取消选择""" + # 这个方法现在由全局点击处理方法统一处理 + self._handle_global_click(event) + + def _handle_global_click(self, event): + """处理全局左键点击事件,在空白区域取消选择""" + # 检查点击的控件是否是应用卡片或其子控件 + clicked_widget = event.widget + + # 向上遍历控件树,检查是否点击在应用卡片上 + current_widget = clicked_widget + is_app_item = False + + for _ in range(10): # 增加检查层数,确保能找到应用卡片 + if hasattr(current_widget, 'app_index'): + is_app_item = True + break + try: + current_widget = current_widget.master + if not current_widget: + break + except: + break + + # 检查是否点击在交互控件上(按钮、输入框等) + widget_class = clicked_widget.__class__.__name__ + is_interactive = any(control_type in widget_class for control_type in + ['Button', 'Entry', 'Textbox', 'Combobox', 'Checkbutton', + 'Radiobutton', 'Scale', 'Scrollbar']) + + # 如果不是点击在应用卡片上,也不是交互控件,且有选中项,则取消选择 + if not is_app_item and not is_interactive and self.selected_items: + self._log("Global click - clearing selection of {len(self.selected_items)} apps", "DEBUG") + self.selected_items.clear() + self.last_selected_index = None + self._update_selection_display() + + def _setup_global_click_binding(self): + """设置全局点击事件绑定""" + def bind_recursive(widget): + """递归绑定所有子控件的点击事件""" + try: + # 跳过应用卡片,它们有自己的点击处理 + if hasattr(widget, 'app_index'): + return + + # 跳过已经有特定点击处理的控件 + widget_class = widget.__class__.__name__ + if any(control_type in widget_class for control_type in + ['Button', 'Entry', 'Textbox', 'Combobox']): + return + + # 绑定点击事件 + widget.bind("", self._handle_global_click, "+") + + # 递归绑定子控件 + for child in widget.winfo_children(): + bind_recursive(child) + except Exception: + pass # 忽略绑定错误 + + # 从根窗口开始递归绑定 + bind_recursive(self) + + def _show_empty_area_menu(self, event): + """在空白区域显示右键菜单""" + from tkinter import Menu + + # 创建现代化的右键菜单 + menu = Menu(self, tearoff=0, + bg="#2d2d30", fg="#ffffff", + activebackground="#0e639c", activeforeground="#ffffff", + selectcolor="#ffffff", + relief="flat", borderwidth=1, bd=1, + font=("Segoe UI", 11, "normal")) + + # 全选选项 + current_project = self.project_combo.get() + if current_project: + apps = self.config_manager.get_apps(current_project) + if apps: + menu.add_command( + label=f"全选 ({len(apps)} 项)", + command=self._select_all_apps_direct + ) + menu.add_separator() + + # 粘贴选项 + if self.clipboard_apps: + menu.add_command( + label=f"粘贴 ({len(self.clipboard_apps)} 项)", + command=self._paste_apps + ) + + # 如果菜单为空,添加提示 + if menu.index("end") is None: + menu.add_command(label="无可用操作", state="disabled") + + # 显示菜单 + try: + menu.tk_popup(event.x_root, event.y_root) + finally: + menu.grab_release() + + def _show_context_menu(self, event, index: int): + """显示应用项右键菜单""" + from tkinter import Menu + + # 如果右键点击的项不在选中列表中,则只选中当前项 + if index not in self.selected_items: + self.selected_items = [index] + self.last_selected_index = index + self._update_selection_display() + + # 创建现代化的右键菜单 + menu = Menu(self, tearoff=0, + bg="#2d2d30", fg="#ffffff", + activebackground="#0e639c", activeforeground="#ffffff", + selectcolor="#ffffff", + relief="flat", borderwidth=1, bd=1, + font=("Segoe UI", 11, "normal")) + + # 选择相关操作 + current_project = self.project_combo.get() + if current_project: + apps = self.config_manager.get_apps(current_project) + if len(apps) > len(self.selected_items): + menu.add_command( + label=f"全选 ({len(apps)} 项)", + command=self._select_all_apps_direct + ) + menu.add_separator() + + # 编辑操作 + menu.add_command( + label=f"复制 ({len(self.selected_items)} 项)", + command=self._copy_apps + ) + + menu.add_command( + label=f"剪切 ({len(self.selected_items)} 项)", + command=self._cut_apps + ) + + # 粘贴操作 + if self.clipboard_apps: + menu.add_command( + label=f"粘贴 ({len(self.clipboard_apps)} 项)", + command=self._paste_apps + ) + + # 分隔符 + menu.add_separator() + + # 删除操作 + menu.add_command( + label=f"删除 ({len(self.selected_items)} 项)", + command=self._delete_selected_apps + ) + + # 显示菜单 + try: + menu.tk_popup(event.x_root, event.y_root) + finally: + menu.grab_release() + + def _copy_apps(self, menu=None): + """复制选中的应用到剪贴板""" + if menu: + menu.destroy() + + # 获取界面上当前选择的项目 + current_project = self.project_combo.get() + if not current_project: + return + + apps = self.config_manager.get_apps(current_project) + + # 复制选中的应用数据 + self.clipboard_apps = [] + for idx in sorted(self.selected_items): + if idx < len(apps): + # 深拷贝应用数据 + app_copy = apps[idx].copy() + self.clipboard_apps.append(app_copy) + + self._log("Copied {len(self.clipboard_apps)} apps to clipboard", "DEBUG") + + def _cut_apps(self, menu=None): + """剪切选中的应用到剪贴板""" + if menu: + menu.destroy() + + # 先复制 + self._copy_apps() + + # 然后删除选中的应用 + # 获取界面上当前选择的项目 + current_project = self.project_combo.get() + if not current_project: + return + + apps = self.config_manager.get_apps(current_project) + + # 从后往前删除,避免索引变化 + for idx in sorted(self.selected_items, reverse=True): + if idx < len(apps): + self.config_manager.delete_app(current_project, idx) + + # 清空选中列表 + self.selected_items = [] + self.last_selected_index = None + + # 重新加载 + self._load_apps() + self.on_update() + + self._log(f"Cut {len(self.clipboard_apps)} apps", "DEBUG") + + def _paste_apps(self, menu=None): + """粘贴剪贴板中的应用到当前项目""" + if menu: + menu.destroy() + + if not self.clipboard_apps: + custom_dialogs.show_warning(self, "警告", "剪贴板为空") + return + + # 获取界面上当前选择的项目,而不是配置文件中的当前项目 + current_project = self.project_combo.get() + if not current_project: + custom_dialogs.show_warning(self, "警告", "请先选择一个项目") + return + + + # 获取目标项目的现有应用 + existing_apps = self.config_manager.get_apps(current_project) + + # 粘贴所有应用(智能去重) + success_count = 0 + skipped_count = 0 + + for app in self.clipboard_apps: + # 检查是否已存在完全相同的应用(路径、版本都相同) + is_duplicate = False + for existing_app in existing_apps: + if (existing_app['path'] == app['path'] and + existing_app['version'] == app['version']): + # 完全相同的应用,跳过 + is_duplicate = True + skipped_count += 1 + self._log(f"Skipping duplicate app: {app['name']} (same path and version)", "DEBUG") + break + + if is_duplicate: + continue + + # 检查是否存在相同路径但版本不同的应用 + has_same_path = any(existing_app['path'] == app['path'] for existing_app in existing_apps) + + if has_same_path: + # 路径相同但版本不同,添加版本后缀 + app['name'] = f"{app['name']} (v{app['version']})" + self._log(f"Same path but different version, renamed to: {app['name']}", "DEBUG") + + # 检查名称是否重复,如果重复则添加数字后缀 + base_name = app['name'] + counter = 1 + while any(existing_app['name'] == app['name'] for existing_app in existing_apps): + app['name'] = f"{base_name} ({counter})" + counter += 1 + + # 添加应用 + if self.config_manager.add_app(current_project, app['name'], app['path'], app['version']): + # 复制图标设置 + icon = self.config_manager.get_app_icon(app['path']) + if icon: + self.config_manager.set_app_icon(app['path'], icon) + + # 复制颜色设置 + color = self.config_manager.get_app_color(app['path']) + if color: + self.config_manager.set_app_color(app['path'], color) + + success_count += 1 + # 更新现有应用列表,以便后续检查 + existing_apps = self.config_manager.get_apps(current_project) + + # 重新加载 + self._load_apps() + self.on_update() + + # 显示结果消息 + if skipped_count > 0: + message = f"已粘贴 {success_count} 个应用到 {current_project}\n跳过 {skipped_count} 个重复应用" + else: + message = f"已粘贴 {success_count} 个应用到 {current_project}" + + custom_dialogs.show_info(self, "成功", message) + self._log(f"Pasted {success_count} apps, skipped {skipped_count} duplicates", "DEBUG") + + def _delete_selected_apps(self): + """删除选中的应用""" + if not self.selected_items: + return + + # 确认删除 + result = custom_dialogs.ask_yes_no( + self, + "Confirm Delete", + f"Are you sure you want to delete the selected {len(self.selected_items)} applications?" + ) + + if not result: + return + + # 获取界面上当前选择的项目 + current_project = self.project_combo.get() + if not current_project: + return + + apps = self.config_manager.get_apps(current_project) + + # 从后往前删除,避免索引变化 + for idx in sorted(self.selected_items, reverse=True): + if idx < len(apps): + self.config_manager.delete_app(current_project, idx) + + # 清空选中列表 + self.selected_items = [] + self.last_selected_index = None + + # 重新加载 + self._load_apps() + self.on_update() + + self._log("Deleted selected apps", "DEBUG") + + def _clear_selection(self): + """清除所有选择""" + self.selected_items = [] + self.last_selected_index = None + self._update_selection_display() + + def _select_all_apps(self): + """全选当前项目的所有应用""" + current_project = self.project_combo.get() + if not current_project: + return + + # 获取当前项目的所有应用 + apps = self.config_manager.get_apps(current_project) + + if not apps: + # 清空选择状态,确保UI一致性 + self.selected_items = [] + self.last_selected_index = None + self._update_selection_display() + self._log("No apps to select in empty project", "DEBUG") + return + + # 选择所有应用 + self.selected_items = list(range(len(apps))) + self.last_selected_index = len(apps) - 1 if apps else None + self._update_selection_display() + + self._log(f"Selected all {len(apps)} apps", "DEBUG") + + # 为了兼容性,保留别名 + def _select_all_apps_direct(self): + """直接全选所有应用(别名方法)""" + return self._select_all_apps() + + # 基本快捷键处理函数 + + def _handle_delete(self, event): + """处理 Delete""" + if self.winfo_exists() and self.state() == 'normal': + focused = self.focus_get() + if focused and isinstance(focused, (ctk.CTkEntry, ctk.CTkTextbox)): + return # 让文本框处理自己的 Delete + + self._delete_selected_apps() + return "break" + + def _handle_escape(self, event): + """处理 Escape""" + if self.winfo_exists() and self.state() == 'normal': + self._clear_selection() + return "break" + + def _set_project_icon(self): + """设置项目图标""" + current_project = self._ensure_project_selected() + if not current_project: + return + + # 打开文件选择对话框 + icon_path = filedialog.askopenfilename( + title="选择项目图标", + filetypes=[ + ("图片文件", "*.png *.jpg *.jpeg *.ico *.bmp *.gif"), + ("所有文件", "*.*") + ] + ) + + if icon_path: + # 保存图标路径 + if self.config_manager.set_project_icon(current_project, icon_path): + custom_dialogs.show_info(self, "成功", f"项目 '{current_project}' 的图标已设置") + # 通知主窗口更新 + self.on_update() + else: + custom_dialogs.show_error(self, "错误", "设置图标失败") + + def _set_project_color(self): + """设置项目背景颜色""" + current_project = self._ensure_project_selected() + if not current_project: + return + + # 打开颜色选择对话框 + from tkinter import colorchooser + color = colorchooser.askcolor( + title="选择项目背景颜色", + initialcolor=self.config_manager.get_project_color(current_project) + ) + + if color and color[1]: # color[1] 是十六进制颜色值 + # 保存颜色 + if self.config_manager.set_project_color(current_project, color[1]): + custom_dialogs.show_info(self, "成功", f"项目 '{current_project}' 的背景颜色已设置") + # 通知主窗口更新 + self.on_update() + else: + custom_dialogs.show_error(self, "错误", "设置颜色失败") diff --git a/ui/splash_screen.py b/ui/splash_screen.py new file mode 100644 index 0000000..1f620f7 --- /dev/null +++ b/ui/splash_screen.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +启动画面 - 使用customtkinter实现,带淡入淡出动画 +""" +import customtkinter as ctk + + +class SplashScreen(ctk.CTkToplevel): + """启动画面 - 扁平化设计,带动画特效""" + + def __init__(self, parent): + super().__init__(parent) + + # 无边框窗口 + self.overrideredirect(True) + + # 窗口大小和位置(更扁平) + width = 500 + height = 160 + screen_width = self.winfo_screenwidth() + screen_height = self.winfo_screenheight() + x = (screen_width - width) // 2 + y = (screen_height - height) // 2 + self.geometry(f"{width}x{height}+{x}+{y}") + + # 背景色 - 深色 + bg_color = "#0d1117" + border_color = "#58a6ff" + + # 设置窗口背景为边框颜色,创造边框效果 + self.configure(fg_color=border_color) + + # 主框架(带圆角,通过padding创造边框效果) + main_frame = ctk.CTkFrame( + self, + fg_color=bg_color, + corner_radius=10 + ) + # 增大padding,让圆角更明显 + main_frame.pack(expand=True, fill="both", padx=2.5, pady=2.5) + + # 内容容器 + content_frame = ctk.CTkFrame(main_frame, fg_color="transparent") + content_frame.pack(expand=True, fill="both", padx=20, pady=15) + + # 标题 - 使用更酷的字体和渐变色效果 + title_frame = ctk.CTkFrame(content_frame, fg_color="transparent") + title_frame.pack(pady=(10, 5)) + + self.title_label = ctk.CTkLabel( + title_frame, + text="⚡ NEXUS LAUNCHER", + font=ctk.CTkFont(size=32, weight="bold", family="Consolas"), + text_color="#58a6ff" + ) + self.title_label.pack() + + # 副标题 - 科技感 + self.subtitle_label = ctk.CTkLabel( + content_frame, + text="[ INITIALIZING SYSTEM ]", + font=ctk.CTkFont(size=11, family="Consolas"), + text_color="#8b949e" + ) + self.subtitle_label.pack(pady=(0, 15)) + + # 进度条容器 + progress_container = ctk.CTkFrame(content_frame, fg_color="transparent") + progress_container.pack(fill="x", pady=(0, 10)) + + # 进度条 - 更粗,带发光效果 + self.progress = ctk.CTkProgressBar( + progress_container, + width=440, + height=12, + mode='indeterminate', + progress_color="#58a6ff", + fg_color="#161b22", + border_width=0, + corner_radius=6 + ) + self.progress.pack() + self.progress.start() + + # 状态文本 - 动画效果 + self.status_label = ctk.CTkLabel( + content_frame, + text="Loading modules...", + font=ctk.CTkFont(size=9, family="Consolas"), + text_color="#6e7681" + ) + self.status_label.pack() + + # 置顶 + self.attributes('-topmost', True) + + # 初始透明度动画 + self.alpha = 0.0 + self.attributes('-alpha', self.alpha) + self._fade_in() + + self.update() + + def _fade_in(self): + """淡入动画""" + if self.alpha < 1.0: + self.alpha += 0.1 + self.attributes('-alpha', self.alpha) + self.after(20, self._fade_in) + + def _fade_out(self, callback): + """淡出动画""" + if self.alpha > 0.0: + self.alpha -= 0.15 + self.attributes('-alpha', self.alpha) + self.after(20, lambda: self._fade_out(callback)) + else: + callback() + + def update_status(self, message): + """更新状态信息""" + try: + self.status_label.configure(text=message) + self.update() + except: + pass + + def close(self): + """关闭启动画面(带淡出动画)""" + try: + self.progress.stop() + self._fade_out(self.destroy) + except: + try: + self.destroy() + except: + pass diff --git a/ui/task/__init__.py b/ui/task/__init__.py new file mode 100644 index 0000000..befb5b3 --- /dev/null +++ b/ui/task/__init__.py @@ -0,0 +1,12 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""Task package for NexusLauncher. + +Exports the core task-related classes. +""" + +from .node import Node +from .subfolder_editor import NodeEditor +from .task_panel import TaskPanel + +__all__ = ["Node", "NodeEditor", "TaskPanel"] diff --git a/ui/task/node.py b/ui/task/node.py new file mode 100644 index 0000000..6cea572 --- /dev/null +++ b/ui/task/node.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Node Module +----------- +Defines the Node class for representing folder nodes in the structure. +""" +import uuid +from typing import List, Optional, Dict, Any + + +class Node: + """Represents a folder node in the structure with parent-child relationships. + + Attributes: + id (str): Unique identifier for the node + name (str): Display name of the node + parent_id (Optional[str]): ID of the parent node, None for root nodes + children (List[Node]): List of child nodes + x (float): X-coordinate for display + y (float): Y-coordinate for display + width (float): Width of the node in pixels + height (float): Height of the node in pixels + """ + + def __init__(self, name: str, node_id: Optional[str] = None, parent_id: Optional[str] = None): + """Initialize a new node. + + Args: + name: The display name of the node + node_id: Optional unique identifier. If not provided, a UUID will be generated. + parent_id: Optional ID of the parent node. + """ + self.id = node_id or str(uuid.uuid4()) + self.name = name + self.parent_id = parent_id + self.children: List['Node'] = [] + self.x = 0.0 + self.y = 0.0 + self.width = 120.0 + self.height = 60.0 + + def to_dict(self) -> Dict[str, Any]: + """Convert the node and its children to a dictionary. + + Returns: + A dictionary representation of the node and its children. + """ + return { + 'id': self.id, + 'name': self.name, + 'parent_id': self.parent_id, + 'x': self.x, + 'y': self.y, + 'width': self.width, + 'height': self.height, + 'children': [child.to_dict() for child in self.children] + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'Node': + """Create a Node instance from a dictionary. + + Args: + data: Dictionary containing node data + + Returns: + A new Node instance with the given data + """ + node = cls( + name=data['name'], + node_id=data['id'], + parent_id=data.get('parent_id') + ) + node.x = data.get('x', 0) + node.y = data.get('y', 0) + node.width = data.get('width', 120) + node.height = data.get('height', 60) + + # Recursively create child nodes + for child_data in data.get('children', []): + child_node = cls.from_dict(child_data) + child_node.parent_id = node.id + node.children.append(child_node) + + return node + + def add_child(self, name: str) -> 'Node': + """Add a new child node. + + Args: + name: Name for the new child node + + Returns: + The newly created child node + """ + child = Node(name, parent_id=self.id) + self.children.append(child) + return child + + def remove_child(self, node_id: str) -> bool: + """Remove a child node by ID. + + Args: + node_id: ID of the node to remove + + Returns: + True if the node was found and removed, False otherwise + """ + for i, child in enumerate(self.children): + if child.id == node_id: + self.children.pop(i) + return True + if child.remove_child(node_id): + return True + return False + + def find_node(self, node_id: str) -> Optional['Node']: + """Find a node by ID in the subtree. + + Args: + node_id: ID of the node to find + + Returns: + The found node, or None if not found + """ + if self.id == node_id: + return self + + for child in self.children: + found = child.find_node(node_id) + if found: + return found + + return None + + def __repr__(self) -> str: + return f"" diff --git a/ui/task/subfolder_editor.py b/ui/task/subfolder_editor.py new file mode 100644 index 0000000..b720be3 --- /dev/null +++ b/ui/task/subfolder_editor.py @@ -0,0 +1,1695 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Node Editor Module +----------------- +A canvas-based node editor for creating and managing folder structures. +""" +import tkinter as tk +import customtkinter as ctk +import time +from typing import Dict, List, Tuple, Optional, Any, Callable +from .node import Node +from ..utilities.icon_utils import get_icon_path +from config.constants import ( + NODE_CANVAS_BG, + NODE_GRID_COLOR, + NODE_BG_COLOR, + NODE_BORDER_COLOR, + NODE_SELECTED_BORDER, + NODE_ID_TEXT_COLOR, + NODE_INPUT_COLOR, + NODE_OUTPUT_COLOR, + NODE_CONNECTION_COLOR, + NODE_CONNECTION_SELECTED, + NODE_COLOR_PALETTE, + DIALOG_BG_COLOR, + DIALOG_TEXT_COLOR, + COLOR_SUCCESS, + COLOR_SUCCESS_HOVER, + BUTTON_GRAY, + BUTTON_GRAY_HOVER, + DIALOG_NODE_RENAME_SIZE +) + +class NodeEditor(tk.Canvas): + """A canvas-based node editor for creating and managing folder structures. + + This class provides a visual interface for creating, editing, and managing + nodes that represent folder structures. It supports drag-and-drop, + parent-child relationships, and various node operations. + """ + + # 类级别的剪贴板,支持跨面板粘贴 + _clipboard = None + + # 调试模式控制 + debug_mode = False + + def _log(self, message: str, level: str = "INFO"): + """统一的日志方法 + + Args: + message: 日志消息 + level: 日志级别 (DEBUG, INFO, WARNING, ERROR) + """ + # DEBUG级别的日志只在调试模式下输出 + if level == "DEBUG" and not self.debug_mode: + return + + prefix = f"[{level}]" + full_message = f"{prefix} {message}" + print(full_message) + + def _find_node_at_position(self, x: float, y: float) -> Optional[Node]: + """查找指定位置的节点""" + for node in self.nodes: + if (node.x <= x <= node.x + node.width and + node.y <= y <= node.y + node.height): + return node + return None + + def _find_node_by_id(self, node_id: str) -> Optional[Node]: + """根据ID查找节点""" + return next((node for node in self.nodes if node.id == node_id), None) + + def __init__(self, parent, **kwargs): + """Initialize the NodeEditor as a plain Canvas. + + Scrolling is handled by the outer TaskPanel; this widget only draws + nodes and connections on its own canvas. + + Args: + parent: The parent widget + **kwargs: Additional keyword arguments for the Canvas + """ + # Give the canvas a reasonably large logical area so nodes are not + # squeezed into a very small region. The outer CTkCanvas will handle + # the actual visible viewport. + default_kwargs = { + "bg": NODE_CANVAS_BG, # 更深的背景色 + "highlightthickness": 0, + "width": 3000, # 增大画布尺寸 + "height": 3000, + } + default_kwargs.update(kwargs) + super().__init__(parent, **default_kwargs) + + # Node management + self.nodes: List[Node] = [] + self.connections: List[Tuple[str, str, int]] = [] # (parent_id, child_id, canvas_id) + self.selected_nodes: List[Node] = [] # 多选节点列表 + self.selected_node: Optional[Node] = None # 保留用于兼容性 + self.selected_connections: List[Tuple[str, str, int]] = [] # 多选连接线列表 + self.selected_connection: Optional[Tuple[str, str, int]] = None # 保留用于兼容性 + self.drag_data = {"x": 0, "y": 0, "item": None} + self.connection_start: Optional[str] = None + self._center_after_id = None # 用于跟踪待处理的居中任务 + self.context_menu: Optional[tk.Menu] = None + self.clipboard: Optional[Node] = None + + # 框选 + self.selection_rect = None + self.selection_start = None + self.is_box_selecting = False + + # 右键长按检测 + self.right_click_start_time = 0 + self.right_click_threshold = 0.3 # 300ms + + # 缩放和平移 + self.scale = 1.0 # 缩放比例 + self.pan_start_x = 0 + self.pan_start_y = 0 + self.is_panning = False + + # 临时连接线 + self.temp_connection_line = None + self.is_dragging_connection = False + + # Bind events + self.bind("", self.on_click) + self.bind("", self.on_drag) + self.bind("", self.on_release) + self.bind("", self.on_right_button_press) # 右键按下 + self.bind("", self.on_right_drag) # 右键拖动 + self.bind("", self.on_right_button_release) # 右键释放 + self.bind("", self.on_zoom) # 滚轮缩放 + self.bind("", self.delete_selected) + self.bind("", self.copy_selected) + self.bind("", self.paste_node) + self.bind("", self.duplicate_selected) + self.bind("", self.fit_all_nodes) # Ctrl+0 适应所有节点 + self.bind("", self.fit_all_nodes) # Home键也可以适应所有节点 + self.bind("", self.emergency_reset_view) # F5紧急重置视图 + self.bind("", self.rename_selected_node) # F2重命名选中节点 + + # 设置为可获得焦点 + self.focus_set() + + # 点击时获取焦点 + self.bind("", lambda e: self._log("[FOCUS] NodeEditor gained focus", "DEBUG")) + self.bind("", lambda e: self._log("[FOCUS] NodeEditor lost focus", "DEBUG")) + + # Ensure scrollregion always covers all drawn items. The outer canvas + # reads this scrollregion to determine how much area can be scrolled. + self.bind("", self._on_configure) + + # 绘制网格背景 + self._draw_grid() + + def _canvas_coords(self, event_x, event_y): + """将事件坐标转换为画布坐标 + + Args: + event_x: 事件的 x 坐标 + event_y: 事件的 y 坐标 + + Returns: + (canvas_x, canvas_y): 画布坐标 + """ + return (self.canvasx(event_x), self.canvasy(event_y)) + + def _draw_grid(self): + """绘制网格背景""" + grid_size = 50 + width = 3000 + height = 3000 + + # 绘制垂直线 + for x in range(0, width, grid_size): + self.create_line(x, 0, x, height, fill=NODE_GRID_COLOR, width=1, tags='grid') + + # 绘制水平线 + for y in range(0, height, grid_size): + self.create_line(0, y, width, y, fill=NODE_GRID_COLOR, width=1, tags='grid') + + # 将网格移到最底层 + self.tag_lower('grid') + + def _on_configure(self, event=None): + """Update scrollregion to include all items on the canvas. + + 注意:此方法被禁用以避免干扰居中逻辑。 + 滚动区域现在由 _simple_center_view 方法管理。 + """ + # 禁用自动调整滚动区域,避免破坏居中效果 + # bbox = self.bbox("all") + # if bbox: + # self.configure(scrollregion=bbox) + pass + + def add_node(self, name: str, x: float, y: float, parent_id: Optional[str] = None) -> Node: + """Add a new node to the editor. + + Args: + name: The name of the node + x: X coordinate + y: Y coordinate + parent_id: Optional parent node ID + + Returns: + The created node + """ + node = Node(name, parent_id=parent_id) + node.x = x + node.y = y + self.nodes.append(node) + self.draw_node(node) + + # If we have a parent, create a connection + if parent_id: + parent = self._find_node_by_id(parent_id) + if parent: + parent.children.append(node) + self.draw_connection(parent, node) + + return node + + def _create_rounded_rect(self, x1, y1, x2, y2, radius=10, **kwargs): + """创建圆角矩形 + + Args: + x1, y1: 左上角坐标 + x2, y2: 右下角坐标 + radius: 圆角半径 + **kwargs: 其他 canvas 参数 + + Returns: + 创建的图形 ID + """ + points = [ + x1 + radius, y1, + x1 + radius, y1, + x2 - radius, y1, + x2 - radius, y1, + x2, y1, + x2, y1 + radius, + x2, y1 + radius, + x2, y2 - radius, + x2, y2 - radius, + x2, y2, + x2 - radius, y2, + x2 - radius, y2, + x1 + radius, y2, + x1 + radius, y2, + x1, y2, + x1, y2 - radius, + x1, y2 - radius, + x1, y1 + radius, + x1, y1 + radius, + x1, y1 + ] + return self.create_polygon(points, smooth=True, **kwargs) + + def _get_node_color(self, name: str) -> str: + """根据节点名称生成一致的颜色 + + Args: + name: 节点名称 + + Returns: + 十六进制颜色代码 + """ + # 预定义的颜色调色板(饱和度和亮度适中) + color_palette = NODE_COLOR_PALETTE + + # 特殊节点使用固定颜色 + if name == 'TaskFolder': + return NODE_COLOR_PALETTE[0] # 蓝色 + + # 使用名称的哈希值选择颜色 + hash_value = hash(name) + color_index = abs(hash_value) % len(color_palette) + return color_palette[color_index] + + def draw_node(self, node: Node): + """Draw a node with flat modern design. + + Args: + node: The node to draw + """ + x, y = node.x, node.y + + # 判断节点是否被选中(支持多选) + is_selected = node in self.selected_nodes + + # 根据节点名称生成一致的颜色 + header_color = self._get_node_color(node.name) + + # Node background (主体背景) + bg_color = NODE_BG_COLOR + # 选中时使用更明显的边框 + if is_selected: + border_color = NODE_SELECTED_BORDER # 青色高亮 + border_width = 3 + else: + border_color = NODE_BORDER_COLOR + border_width = 1 + + node_rect = self._create_rounded_rect( + x, y, x + node.width, y + node.height, + radius=12, # 增大圆角 + fill=bg_color, + outline=border_color, + width=border_width, + tags=('node', f'node_{node.id}', f'node_rect_{node.id}') + ) + + # Node header bar (顶部标题栏) + self._create_rounded_rect( + x, y, x + node.width, y + 24, + radius=12, # 增大圆角 + fill=header_color, + outline='', + tags=('node_header', f'node_{node.id}', f'node_header_{node.id}') + ) + + # Node name (节点名称) + self.create_text( + x + node.width // 2, y + 12, + text=node.name, + fill='white', + font=('Segoe UI', 9, 'bold'), + tags=('node_text', f'node_{node.id}', f'node_text_{node.id}') + ) + + # Node ID (简短ID显示) + self.create_text( + x + node.width // 2, y + 40, + text=f"ID: {node.id[:6]}", + fill=NODE_ID_TEXT_COLOR, + font=('Consolas', 7), + tags=('node_id', f'node_{node.id}', f'node_id_{node.id}') + ) + + # Input connection point (顶部输入点) + input_x = x + node.width / 2.0 # 使用浮点数确保精度 + input_y = y + self.create_oval( + input_x - 6, input_y - 6, + input_x + 6, input_y + 6, + fill=NODE_INPUT_COLOR, + outline='white', + width=2, + tags=('input_point', f'node_{node.id}', f'input_point_{node.id}') + ) + + # Output connection point (底部输出点) + output_x = x + node.width / 2.0 # 使用浮点数确保精度 + output_y = y + node.height + self.create_oval( + output_x - 6, output_y - 6, + output_x + 6, output_y + 6, + fill=NODE_OUTPUT_COLOR, + outline='white', + width=2, + tags=('connection_point', f'node_{node.id}', f'connection_point_{node.id}') + ) + + def draw_connection(self, parent: Node, child: Node): + """Draw a smooth bezier connection (vertical). + + Args: + parent: The parent node + child: The child node + """ + # 精确计算连接点位置(从底部到顶部) + start_x = parent.x + parent.width / 2.0 # 使用浮点数确保精度 + start_y = parent.y + parent.height # 父节点底部 + end_x = child.x + child.width / 2.0 + end_y = child.y # 子节点顶部 + + # 计算贝塞尔曲线控制点(垂直方向) + distance = abs(end_y - start_y) + control_offset = min(distance * 0.5, 100) + + # 使用多个点模拟垂直贝塞尔曲线 + points = [ + start_x, start_y, + start_x, start_y + control_offset, + end_x, end_y - control_offset, + end_x, end_y + ] + + # 检查连接线是否被选中 + is_selected = any( + conn[0] == parent.id and conn[1] == child.id + for conn in self.selected_connections + ) + + # 根据选中状态设置颜色和宽度 + line_color = NODE_CONNECTION_SELECTED if is_selected else NODE_CONNECTION_COLOR + line_width = 3 if is_selected else 2 + + # 主连接线 - 使用更高的 splinesteps 使线条更流畅 + connection = self.create_line( + *points, + fill=line_color, + width=line_width, + smooth=True, + splinesteps=50, # 增加到 50 使线条更流畅 + capstyle=tk.ROUND, # 圆形端点 + joinstyle=tk.ROUND, # 圆形连接 + arrow=tk.LAST, # 在末端添加箭头 + arrowshape=(10, 12, 4), # 箭头形状 (长度, 宽度, 厚度) + tags=('connection', f'connection_{parent.id}_{child.id}') + ) + + self.connections.append((parent.id, child.id, connection)) + self.tag_lower(connection) + + def on_click(self, event): + """Handle mouse click events.""" + self.focus_set() + canvas_x, canvas_y = self._canvas_coords(event.x, event.y) + + item = self.find_withtag(tk.CURRENT) + if not item: + self._handle_empty_click(canvas_x, canvas_y) + return + + tags = self.gettags(item) + + # 处理不同类型的点击 + if 'connection' in tags: + self._handle_connection_click(tags, event.state) + elif 'connection_point' in tags or 'input_point' in tags: + self._handle_connection_point_click(tags) + elif any(tag.startswith('node_') and not tag.startswith(('node_text', 'node_header')) for tag in tags): + self._handle_node_click(tags, event.state, canvas_x, canvas_y) + else: + self._handle_empty_click(canvas_x, canvas_y) + + def _handle_connection_click(self, tags, state): + """处理连接线点击""" + for tag in tags: + if not tag.startswith('connection_'): + continue + + parts = tag.split('_') + if len(parts) < 3: + continue + + parent_id, child_id = parts[1], parts[2] + conn = self._find_connection(parent_id, child_id) + if not conn: + continue + + is_ctrl = bool(state & 0x4) + + if is_ctrl: + # 切换连接选择状态 + if conn in self.selected_connections: + self.selected_connections.remove(conn) + else: + self.selected_connections.append(conn) + else: + # 只选择指定连接 + self.selected_connections = [conn] + self.selected_connection = conn + + self.redraw_all() + return + + def _handle_connection_point_click(self, tags): + """处理连接点点击""" + # 从标签中提取节点ID + node_id = None + for tag in tags: + if tag.startswith('connection_point_') or tag.startswith('input_point_'): + node_id = tag.split('_')[-1] + break + + if node_id: + self.connection_start = node_id + self.is_dragging_connection = True + self.itemconfig(tk.CURRENT, fill=NODE_CONNECTION_SELECTED, outline=NODE_CONNECTION_SELECTED) + + def _handle_node_click(self, tags, state, canvas_x, canvas_y): + """处理节点点击""" + node_tag = next((tag for tag in tags if tag.startswith('node_') and not tag.startswith(('node_text', 'node_header'))), None) + if not node_tag: + return + + node_id = node_tag.split('_', 1)[1] + clicked_node = self._find_node_by_id(node_id) + if not clicked_node: + return + + # 处理修饰键 + if state & 0x1: # Shift + # 添加节点到选择 + if clicked_node not in self.selected_nodes: + self.selected_nodes.append(clicked_node) + self.selected_node = clicked_node + self._log(f"[SELECT] Shift+click: Added node {clicked_node.name} to selection, total {len(self.selected_nodes)}", "DEBUG") + elif state & 0x4: # Ctrl + # 切换节点选择状态 + if clicked_node in self.selected_nodes: + self.selected_nodes.remove(clicked_node) + if clicked_node == self.selected_node: + self.selected_node = self.selected_nodes[0] if self.selected_nodes else None + else: + self.selected_nodes.append(clicked_node) + self.selected_node = clicked_node + self._log(f"[SELECT] Ctrl+click: Toggled node {clicked_node.name} selection, total {len(self.selected_nodes)}", "DEBUG") + else: + # 普通点击:如果点击的是已选中的节点,保持多选状态;否则单选 + if clicked_node in self.selected_nodes and len(self.selected_nodes) > 1: + # 点击已选中的节点,保持多选状态,只更新主选择节点 + self.selected_node = clicked_node + self._log(f"[SELECT] Normal click on selected node: {clicked_node.name}, maintaining multi-selection ({len(self.selected_nodes)})", "DEBUG") + else: + # 点击未选中的节点,或者只有一个选中节点,进行单选 + self.selected_nodes = [clicked_node] + self.selected_node = clicked_node + self._log(f"[SELECT] Normal click: Selected node {clicked_node.name}", "DEBUG") + + # 使用正确的点击位置设置拖动数据 + self.drag_data = {"x": canvas_x, "y": canvas_y, "item": f'node_{node_id}'} + self.redraw_all() + + def _handle_empty_click(self, canvas_x, canvas_y): + """处理空白区域点击""" + self.selected_nodes = [] + self.selected_node = None + self.selected_connections = [] + self.selected_connection = None + + # 清理拖动数据 + self.drag_data = {"x": 0, "y": 0, "item": None} + + self.selection_start = (canvas_x, canvas_y) + self.is_box_selecting = True + self._log(f"[BOX_SELECT] Start selection: ({canvas_x}, {canvas_y})", "DEBUG") + + def _find_connection(self, parent_id, child_id): + """查找连接""" + for conn in self.connections: + if conn[0] == parent_id and conn[1] == child_id: + return conn + return None + + def on_drag(self, event): + """Handle node dragging, connection dragging, and box selection.""" + # 转换为画布坐标 + canvas_x, canvas_y = self._canvas_coords(event.x, event.y) + + # 处理框选 + if self.is_box_selecting and self.selection_start: + # 删除旧的选择框 + if self.selection_rect: + self.delete(self.selection_rect) + + # 绘制新的选择框 + x1, y1 = self.selection_start + self.selection_rect = self.create_rectangle( + x1, y1, canvas_x, canvas_y, + outline=NODE_CONNECTION_COLOR, + width=2, + dash=(5, 3), + tags='selection_rect' + ) + return + + # 处理连接线拖动 + if self.is_dragging_connection and self.connection_start: + if self.temp_connection_line: + self.delete(self.temp_connection_line) + + start_node = next((n for n in self.nodes if n.id == self.connection_start), None) + if start_node: + # 使用浮点数确保精度(从底部输出点开始) + start_x = start_node.x + start_node.width / 2.0 + start_y = start_node.y + start_node.height + + distance = abs(canvas_y - start_y) + control_offset = min(distance * 0.5, 100) + + points = [ + start_x, start_y, + start_x, start_y + control_offset, + canvas_x, canvas_y - control_offset, + canvas_x, canvas_y + ] + + self.temp_connection_line = self.create_line( + *points, + fill=NODE_INPUT_COLOR, + width=2, + smooth=True, + splinesteps=50, # 与正式连接线保持一致 + dash=(5, 3), + capstyle=tk.ROUND, + joinstyle=tk.ROUND, + arrow=tk.LAST, # 在末端添加箭头 + arrowshape=(10, 12, 4), # 箭头形状 (长度, 宽度, 厚度) + tags='temp_connection' + ) + return + + # 处理多节点拖动 + if self.selected_nodes and self.drag_data.get("item"): + dx = canvas_x - self.drag_data["x"] + dy = canvas_y - self.drag_data["y"] + + # 移动所有选中的节点 + for node in self.selected_nodes: + node.x += dx + node.y += dy + + # 更新拖动数据 + self.drag_data = {"x": canvas_x, "y": canvas_y, "item": self.drag_data["item"]} + + # 重绘所有内容以保持同步 + self.redraw_all() + + self._log(f"[DRAG] Batch moved {len(self.selected_nodes)} nodes: dx={dx:.1f}, dy={dy:.1f}", "DEBUG") + + def update_connections(self, node: Node): + """Update all connections for a node. + + Args: + node: The node whose connections need updating + """ + # Update connections where this node is the parent + for child in node.children: + self.delete_connection(node.id, child.id) + self.draw_connection(node, child) + + # Update connections where this node is the child + if node.parent_id: + parent = self._find_node_by_id(node.parent_id) + if parent: + self.delete_connection(parent.id, node.id) + self.draw_connection(parent, node) + + def delete_connection(self, parent_id: str, child_id: str): + """Delete a connection between nodes. + + Args: + parent_id: ID of the parent node + child_id: ID of the child node + """ + for i, (p_id, c_id, conn_id) in enumerate(self.connections[:]): + if p_id == parent_id and c_id == child_id: + self.delete(conn_id) + self.connections.pop(i) + break + + def on_release(self, event): + """Handle mouse release.""" + # 处理框选释放 + if self.is_box_selecting: + if self.selection_rect: + # 获取选择框范围 + coords = self.coords(self.selection_rect) + if len(coords) == 4: + x1, y1, x2, y2 = coords + # 确保 x1 < x2, y1 < y2 + if x1 > x2: + x1, x2 = x2, x1 + if y1 > y2: + y1, y2 = y2, y1 + + # 选中框内的所有节点 + self.selected_nodes = [] + for node in self.nodes: + node_center_x = node.x + node.width // 2 + node_center_y = node.y + node.height // 2 + if x1 <= node_center_x <= x2 and y1 <= node_center_y <= y2: + self.selected_nodes.append(node) + + # 选中框内的所有连接线 + self.selected_connections = [] + for conn in self.connections: + parent_id, child_id, conn_id = conn + parent = next((n for n in self.nodes if n.id == parent_id), None) + child = next((n for n in self.nodes if n.id == child_id), None) + + if parent and child: + # 检查连接线路径上的多个采样点是否在框内(垂直贝塞尔曲线) + start_x = parent.x + parent.width / 2 + start_y = parent.y + parent.height + end_x = child.x + child.width / 2 + end_y = child.y + + # 计算贝塞尔曲线控制点 + distance = abs(end_y - start_y) + control_offset = min(distance * 0.5, 100) + + # 控制点坐标 + cp1_x = start_x + cp1_y = start_y + control_offset + cp2_x = end_x + cp2_y = end_y - control_offset + + # 在贝塞尔曲线上采样多个点 + sample_points = [] + for t in [i * 0.1 for i in range(11)]: # 0.0, 0.1, 0.2, ..., 1.0 + # 三次贝塞尔曲线公式 + # B(t) = (1-t)³P0 + 3(1-t)²tP1 + 3(1-t)t²P2 + t³P3 + t2 = t * t + t3 = t2 * t + mt = 1 - t + mt2 = mt * mt + mt3 = mt2 * mt + + sample_x = (mt3 * start_x + + 3 * mt2 * t * cp1_x + + 3 * mt * t2 * cp2_x + + t3 * end_x) + sample_y = (mt3 * start_y + + 3 * mt2 * t * cp1_y + + 3 * mt * t2 * cp2_y + + t3 * end_y) + sample_points.append((sample_x, sample_y)) + + # 如果任意采样点在框内,则选中该连接线 + for px, py in sample_points: + if x1 <= px <= x2 and y1 <= py <= y2: + self.selected_connections.append(conn) + break + + if self.selected_nodes: + self.selected_node = self.selected_nodes[0] + + self._log(f"[BOX_SELECT] Selected {len(self.selected_nodes)} nodes, {len(self.selected_connections)} connections", "DEBUG") + + # 删除选择框 + self.delete(self.selection_rect) + self.selection_rect = None + + self.is_box_selecting = False + self.selection_start = None + self.redraw_all() + return + + # 处理连接线释放 + if self.is_dragging_connection and self.connection_start: + self._log("Connector release", "DEBUG") + self._log(f"Starting Node ID: {self.connection_start}", "DEBUG") + + # 转换为画布坐标 + canvas_x, canvas_y = self._canvas_coords(event.x, event.y) + self._log(f"Release position: canvas({canvas_x:.0f}, {canvas_y:.0f})", "DEBUG") + + # 删除临时连接线 + if self.temp_connection_line: + self.delete(self.temp_connection_line) + self.temp_connection_line = None + + # 使用画布坐标查找最近的元素 + items = self.find_overlapping(canvas_x-5, canvas_y-5, canvas_x+5, canvas_y+5) + self._log(f"Nearby elements: {items}", "DEBUG") + + # 查找连接点 + target_node_id = None + for item in items: + tags = self.gettags(item) + self._log(f"Tag of item {item}: {tags}", "DEBUG") + + if 'input_point' in tags or 'connection_point' in tags: + # 提取目标节点 ID + for tag in tags: + if tag.startswith('input_point_') or tag.startswith('connection_point_'): + target_node_id = tag.split('_')[-1] + self._log(f"[CONNECTION] Found target node ID: {target_node_id}", "DEBUG") + break + + if target_node_id: + break # 找到连接点就退出循环 + + if target_node_id and target_node_id != self.connection_start: + parent = next((n for n in self.nodes if n.id == self.connection_start), None) + child = next((n for n in self.nodes if n.id == target_node_id), None) + + if parent and child: + # 防止自己连接自己 + if parent.id == child.id: + self._log("[CONNECTION] Cannot connect to self", "DEBUG") + else: + # 如果子节点已经有父节点,先移除旧的连接 + if child.parent_id: + old_parent = next((n for n in self.nodes if n.id == child.parent_id), None) + if old_parent: + if child in old_parent.children: + old_parent.children.remove(child) + # 删除旧连接线 + self.delete_connection(old_parent.id, child.id) + self._log(f"[CONNECTION] Removed old connection: {old_parent.name} -> {child.name}", "DEBUG") + + # 创建新连接(替换旧连接) + if child not in parent.children: + parent.children.append(child) + child.parent_id = parent.id + self._log(f"[CONNECTION] Created new connection: {parent.name} -> {child.name}", "DEBUG") + + # 重置连接状态 + self.connection_start = None + self.is_dragging_connection = False + # 重绘所有节点以恢复连接点颜色 + self.redraw_all() + return + + self.drag_data = {"x": 0, "y": 0, "item": None} + + def on_right_button_press(self, event): + """右键按下 - 记录时间用于检测长按""" + canvas_x, canvas_y = self._canvas_coords(event.x, event.y) + self.right_click_start_time = time.time() + self.right_click_pos = (canvas_x, canvas_y) + self.right_click_event = event # 保存原始事件用于菜单显示 + + def on_right_drag(self, event): + """右键拖动 - 如果移动距离超过阈值,开始拖动画布""" + # 计算移动距离 + if hasattr(self, 'right_click_pos'): + dx = abs(event.x - self.right_click_pos[0]) + dy = abs(event.y - self.right_click_pos[1]) + + # 如果移动距离超过 5 像素,开始拖动 + if dx > 5 or dy > 5: + if not self.is_panning: + self.is_panning = True + self.pan_start_x = event.x + self.pan_start_y = event.y + self.config(cursor="fleur") + + # 拖动画布 + if self.is_panning: + dx = event.x - self.pan_start_x + dy = event.y - self.pan_start_y + + for node in self.nodes: + node.x += dx + node.y += dy + + self.pan_start_x = event.x + self.pan_start_y = event.y + self.redraw_all() + + def on_right_button_release(self, event): + """右键释放 - 判断是短按(菜单)还是长按(拖动)""" + + if self.is_panning: + # 如果正在拖动,停止拖动 + self.is_panning = False + self.config(cursor="") + else: + # 检查是否是短按(没有移动且时间短) + elapsed = time.time() - self.right_click_start_time + if elapsed < self.right_click_threshold: + # 短按:显示右键菜单 + self.show_context_menu(event) + + def on_zoom(self, event): + """滚轮缩放""" + # 转换为画布坐标 + canvas_x, canvas_y = self._canvas_coords(event.x, event.y) + + # 计算缩放因子 + if event.delta > 0: + scale_factor = 1.1 # 放大 + else: + scale_factor = 0.9 # 缩小 + + # 限制缩放范围 + new_scale = self.scale * scale_factor + if 0.5 <= new_scale <= 3.0: # 调整缩放范围,避免过小或过大 + old_scale = self.scale + self.scale = new_scale + + # 以鼠标位置为中心缩放节点位置 + for node in self.nodes: + # 计算节点相对于鼠标的位置 + rel_x = node.x - canvas_x + rel_y = node.y - canvas_y + + # 缩放后的新位置 + node.x = canvas_x + rel_x * (self.scale / old_scale) + node.y = canvas_y + rel_y * (self.scale / old_scale) + + # 节点大小始终基于原始尺寸 * 当前总缩放比例 + node.width = int(120 * self.scale) + node.height = int(60 * self.scale) + + # 重新绘制 + self.redraw_all() + + def redraw_all(self): + """重新绘制所有节点和连接""" + # 删除所有非网格和非临时连接线的元素 + for tag in ['node', 'node_header', 'node_text', 'node_id', + 'connection_point', 'input_point', 'connection']: + self.delete(tag) + + # 清空连接列表 + self.connections.clear() + + # 先绘制所有连接(在底层) + for node in self.nodes: + if node.parent_id: + parent = next((n for n in self.nodes if n.id == node.parent_id), None) + if parent: + self.draw_connection(parent, node) + + # 再绘制所有节点(在顶层) + for node in self.nodes: + self.draw_node(node) + + # 确保网格在最底层 + self.tag_lower('grid') + + def show_context_menu(self, event): + """Show context menu for node operations.""" + # 转换为画布坐标 + canvas_x, canvas_y = self._canvas_coords(event.x, event.y) + + # 使用画布坐标查找最近的元素 + item = self.find_closest(canvas_x, canvas_y) + if item: + tags = self.gettags(item) + node_tag = next((tag for tag in tags if tag.startswith('node_') and not tag.startswith('node_text') and not tag.startswith('node_header')), None) + if node_tag: + node_id = node_tag.split('_', 1)[1] + clicked_node = next((n for n in self.nodes if n.id == node_id), None) + + if clicked_node: + # 检查点击位置是否在节点范围内 + if (clicked_node.x <= canvas_x <= clicked_node.x + clicked_node.width and + clicked_node.y <= canvas_y <= clicked_node.y + clicked_node.height): + + self.selected_node = clicked_node + self.selected_nodes = [clicked_node] + + # Create context menu + if self.context_menu: + self.context_menu.destroy() + + self.context_menu = tk.Menu(self, tearoff=0) + self.context_menu.add_command(label="Add Child", command=self.add_child_node) + + # TaskFolder 根节点不允许重命名和删除 + if self.selected_node.name != "TaskFolder": + self.context_menu.add_command(label="Rename", command=self.rename_node) + self.context_menu.add_separator() + self.context_menu.add_command(label="Delete", command=self.delete_selected) + self.context_menu.add_command(label="Duplicate", command=lambda: self.duplicate_selected(None)) + + # 使用屏幕坐标显示菜单 + self.context_menu.post(event.x_root, event.y_root) + self._log(f"[MENU] Right-click menu: {clicked_node.name} at canvas({canvas_x:.0f}, {canvas_y:.0f})", "DEBUG") + return + + # 右键点击空白区域 - 显示创建节点菜单 + if self.context_menu: + self.context_menu.destroy() + + self.context_menu = tk.Menu(self, tearoff=0) + self.context_menu.add_command( + label="Create Node Here", + command=lambda: self.create_node_at_position(canvas_x, canvas_y) + ) + self.context_menu.post(event.x_root, event.y_root) + self._log(f"[MENU] Empty area right-click menu at canvas({canvas_x:.0f}, {canvas_y:.0f})", "DEBUG") + + def create_node_at_position(self, x: float, y: float): + """在指定位置创建新节点(无父节点)""" + new_node = self.add_node("New Folder", x, y, parent_id=None) + self.selected_node = new_node + self.selected_nodes = [new_node] + self.redraw_all() + self._log(f"[NODE] Created new node at ({x:.0f}, {y:.0f})", "DEBUG") + + def add_child_node(self): + """Add a child node to the selected node.""" + if self.selected_node: + x = self.selected_node.x + 150 + y = self.selected_node.y + 100 + child = self.add_node("New Folder", x, y, self.selected_node.id) + self.selected_node.children.append(child) + self.draw_connection(self.selected_node, child) + + def rename_node(self): + """Rename the selected node with unified dialog style.""" + if not self.selected_node: + return + + # TaskFolder 根节点不允许重命名 + if self.selected_node.name == "TaskFolder": + self._log("The root node of TaskFolder cannot be renamed.", "WARNING") + return + + + # 创建统一样式的对话框 + dialog = ctk.CTkToplevel(self) + dialog.title("重命名节点") + dialog.geometry(DIALOG_NODE_RENAME_SIZE) + dialog.resizable(False, False) + + # 设置对话框背景颜色(与其他对话框一致) + dialog.configure(fg_color=DIALOG_BG_COLOR) + + # 设置图标路径 + icon_path = get_icon_path() + + # 第一次设置图标 + if os.path.exists(icon_path): + try: + dialog.iconbitmap(icon_path) + dialog.wm_iconbitmap(icon_path) + except: + pass + + # 设置为模态对话框 + dialog.transient(self) + dialog.grab_set() + + # 居中显示 + dialog.update_idletasks() + x = (dialog.winfo_screenwidth() // 2) - (400 // 2) + y = (dialog.winfo_screenheight() // 2) - (180 // 2) + dialog.geometry(f"{DIALOG_NODE_RENAME_SIZE}+{x}+{y}") + + # 设置深色标题栏和图标的组合函数 + def apply_title_bar_and_icon(): + # 先设置深色标题栏 + self._set_dark_title_bar(dialog) + # 再次设置图标(确保不被覆盖) + if os.path.exists(icon_path): + try: + dialog.iconbitmap(icon_path) + dialog.wm_iconbitmap(icon_path) + except: + pass + + # 延迟执行 + dialog.after(10, apply_title_bar_and_icon) + # 再次确保图标设置(多次尝试) + dialog.after(100, lambda: dialog.iconbitmap(icon_path) if os.path.exists(icon_path) else None) + dialog.after(200, lambda: dialog.iconbitmap(icon_path) if os.path.exists(icon_path) else None) + + # 主框架 + main_frame = ctk.CTkFrame(dialog, fg_color=DIALOG_BG_COLOR) + main_frame.pack(fill="both", expand=True, padx=0, pady=0) + + # 内容框架 + content_frame = ctk.CTkFrame(main_frame, fg_color="transparent") + content_frame.pack(fill="both", expand=True, padx=20, pady=20) + + # 标签 + label = ctk.CTkLabel( + content_frame, + text="新名称:", + font=("Segoe UI", 12), + text_color=DIALOG_TEXT_COLOR + ) + label.pack(pady=(10, 5)) + + # 输入框 + name_var = tk.StringVar(value=self.selected_node.name) + entry = ctk.CTkEntry( + content_frame, + textvariable=name_var, + font=("Segoe UI", 12), + width=350, + height=35 + ) + entry.pack(pady=5) + entry.select_range(0, 'end') + entry.focus_set() + + # 按钮框架 + button_frame = ctk.CTkFrame(main_frame, fg_color="transparent") + button_frame.pack(pady=(0, 15)) + + def on_ok(): + new_name = name_var.get().strip() + if new_name: + self.selected_node.name = new_name + self.itemconfig(f'node_text_{self.selected_node.id}', text=new_name) + self._log(f"[NODE] Node renamed to: {new_name}", "DEBUG") + dialog.destroy() + + def on_cancel(): + dialog.destroy() + + # 确定按钮 + ok_button = ctk.CTkButton( + button_frame, + text="确定", + command=on_ok, + fg_color=COLOR_SUCCESS, + hover_color=COLOR_SUCCESS_HOVER, + font=("Segoe UI", 12, "bold"), + width=100, + height=35, + corner_radius=8 + ) + ok_button.pack(side="left", padx=5) + + # 取消按钮 + cancel_button = ctk.CTkButton( + button_frame, + text="取消", + command=on_cancel, + fg_color=BUTTON_GRAY, + hover_color=BUTTON_GRAY_HOVER, + font=("Segoe UI", 12, "bold"), + width=100, + height=35, + corner_radius=8 + ) + cancel_button.pack(side="left", padx=5) + + # 绑定快捷键 + dialog.bind('', lambda e: on_ok()) + dialog.bind('', lambda e: on_cancel()) + + def delete_selected(self, event=None): + """Delete the selected nodes or connections.""" + self._log("Press the Delete key", "DEBUG") + self._log(f"Selected connections: {len(self.selected_connections)}", "DEBUG") + self._log(f"Selected nodes: {len(self.selected_nodes)}", "DEBUG") + + # Delete selected connections (supports batch deletion) + if self.selected_connections: + deleted_count = 0 + for conn in self.selected_connections[:]: # 复制列表以避免修改时出错 + parent_id, child_id, conn_id = conn + + # 查找父节点和子节点 + parent = next((n for n in self.nodes if n.id == parent_id), None) + child = next((n for n in self.nodes if n.id == child_id), None) + + if parent and child: + # 移除子节点的父节点引用 + if child in parent.children: + parent.children.remove(child) + child.parent_id = None + + # 删除连接线 + self.delete_connection(parent_id, child_id) + deleted_count += 1 + self._log(f"[CONNECTION] Deleted connection: {parent.name} -> {child.name}", "DEBUG") + + # 清空选择 + self.selected_connections = [] + self.selected_connection = None + + self._log(f"[CONNECTION] Deleted {deleted_count} connections total", "DEBUG") + self.redraw_all() + return + + # 删除所有选中的节点 + if not self.selected_nodes: + return + + # 递归删除节点及其子节点 + def delete_node_recursive(node): + # 先删除所有子节点 + for child in node.children[:]: + delete_node_recursive(child) + + # 从父节点的子节点列表中移除 + if node.parent_id: + parent = next((n for n in self.nodes if n.id == node.parent_id), None) + if parent and node in parent.children: + parent.children.remove(node) + + # 从节点列表中移除 + if node in self.nodes: + self.nodes.remove(node) + + # 删除所有选中的节点(跳过 TaskFolder) + nodes_to_delete = [n for n in self.selected_nodes if n.name != "TaskFolder"] + for node in nodes_to_delete: + delete_node_recursive(node) + + self.selected_nodes = [] + self.selected_node = None + + # 取消连接线高亮 + for conn in self.selected_connections: + self.itemconfig(conn[2], fill=NODE_INPUT_COLOR, width=2) + self.selected_connections = [] + self.selected_connection = None + + self.redraw_all() + + def redraw_connections(self): + """Redraw all connections between nodes.""" + # Remove all connections + for _, _, conn_id in self.connections: + self.delete(conn_id) + self.connections = [] + + # Redraw connections + for node in self.nodes: + for child in node.children: + self.draw_connection(node, child) + + def get_structure(self) -> List[Dict[str, Any]]: + """Get the folder structure as a list of dictionaries. + + Returns: + A list of root nodes and their children as dictionaries + """ + # Only return root nodes, their children will be included recursively + return [node.to_dict() for node in self.nodes if node.parent_id is None] + + def load_structure(self, structure_data: List[Dict[str, Any]]): + """Load folder structure from a list of dictionaries. + + Args: + structure_data: List of node dictionaries to load + """ + self._log(f"Loading structure with {len(structure_data)} root nodes", "DEBUG") + + # Clear existing nodes + self.nodes = [] + self.delete("all") + + # Rebuild the node tree + node_dict = {} # id -> node mapping + + # First pass: create all nodes + def create_nodes(node_data, parent_id=None, depth=0): + node = Node( + node_data["name"], node_id=node_data["id"], parent_id=parent_id + ) + # 使用传入的位置坐标 + node.x = node_data.get("x", 100) + node.y = node_data.get("y", 100) + node.width = node_data.get("width", 120) + node.height = node_data.get("height", 60) + node_dict[node.id] = node + + self._log(f"Creating node: {node.name} at ({node.x}, {node.y})", "DEBUG") + + # Create children recursively + for child_data in node_data.get("children", []): + child_node = create_nodes(child_data, node.id, depth + 1) + node.children.append(child_node) + + return node + + # Create all nodes and build the tree + root_nodes = [] + for node_data in structure_data: + node = create_nodes(node_data) + root_nodes.append(node) + + # 收集所有节点(包括子节点)到 self.nodes + self.nodes = [] + def collect_all_nodes(node): + self.nodes.append(node) + for child in node.children: + collect_all_nodes(child) + + for root_node in root_nodes: + collect_all_nodes(root_node) + + # 绘制所有节点 + for node in self.nodes: + self.draw_node(node) + + # 绘制所有连接 + for node in self.nodes: + if node.parent_id: + parent = next((n for n in self.nodes if n.id == node.parent_id), None) + if parent: + self.draw_connection(parent, node) + + self._log(f"Structure loaded: {len(self.nodes)} total nodes created and drawn", "DEBUG") + + # 取消之前的居中任务 + self._cancel_pending_center() + + # 自动调整视图以显示所有节点 - 使用简化方法 + self._center_after_id = self.after(300, self._ultra_simple_center) + + + + def _cancel_pending_center(self): + """取消待处理的居中任务""" + if hasattr(self, '_center_after_id') and self._center_after_id: + self.after_cancel(self._center_after_id) + self._center_after_id = None + + def force_recenter(self): + """强制重新居中(用于外部调用)""" + self._log("🎯 Manually force centering", "DEBUG") + self._ultra_simple_center() + + def _ultra_simple_center(self): + """动态适配窗口的居中方法 - 根据窗口大小计算中轴线""" + if not self.nodes: + return + + self._log(" Use dynamic adaptation centering method", "DEBUG") + + # 获取实际可用的画布尺寸 + self.update_idletasks() + canvas_width = self.winfo_width() + canvas_height = self.winfo_height() + + # 如果画布尺寸异常,从父窗口估算 + if canvas_width <= 1 or canvas_height <= 1 or canvas_height > 2000: + try: + parent = self.master + if parent: + parent_width = parent.winfo_width() + parent_height = parent.winfo_height() + canvas_width = parent_width - 60 # 减去边距和滚动条 + canvas_height = parent_height - 170 # 减去信息区和按钮 + self._log(f" Use parent window to estimate canvas: {canvas_width}x{canvas_height}", "DEBUG") + else: + # 最后的回退方案 + canvas_width, canvas_height = 1140, 730 + self._log(f" Use default canvas size: {canvas_width}x{canvas_height}", "DEBUG") + except: + canvas_width, canvas_height = 1140, 730 + self._log(f" Use default canvas size: {canvas_width}x{canvas_height}", "DEBUG") + else: + # 实际画布尺寸需要考虑滚动条的影响 + # 垂直滚动条通常占用约15-20像素宽度 + effective_width = canvas_width - 20 # 减去垂直滚动条宽度 + effective_height = canvas_height - 20 # 减去水平滚动条高度 + self._log(f" Original canvas size: {canvas_width}x{canvas_height}", "DEBUG") + self._log(f" Effective canvas size: {effective_width}x{effective_height} (subtracting scrollbar)", "DEBUG") + canvas_width = effective_width + canvas_height = effective_height + + # 计算画布中轴线 - 垂直方向稍微靠上,水平方向微调 + canvas_center_x = canvas_width / 2 + 10 # 稍微向右偏移10像素补正 + canvas_center_y = canvas_height * 0.45 # 从0.5改为0.45,整体靠上一些 + self._log(f" Canvas center: ({canvas_center_x:.0f}, {canvas_center_y:.0f}) [vertical upper, horizontal adjustment]", "DEBUG") + + # 找到根节点 + root_node = None + for node in self.nodes: + if node.name == "TaskFolder" or node.parent_id is None: + root_node = node + break + + if not root_node: + root_node = self.nodes[0] + + # 计算根节点当前中心 + root_center_x = root_node.x + root_node.width / 2 + root_center_y = root_node.y + root_node.height / 2 + self._log(f" Root node center: ({root_center_x:.0f}, {root_center_y:.0f})", "DEBUG") + + # 计算需要的偏移量,让根节点对齐到画布中轴线 + offset_x = canvas_center_x - root_center_x + offset_y = canvas_center_y - root_center_y + self._log(f" Need offset: ({offset_x:.0f}, {offset_y:.0f})", "DEBUG") + + # 如果偏移量较大,实际移动所有节点位置 + if abs(offset_x) > 5 or abs(offset_y) > 5: + self._log(" Move all nodes to adapt to the center line...", "DEBUG") + for node in self.nodes: + node.x += offset_x + node.y += offset_y + + # 重新计算根节点中心 + root_center_x = root_node.x + root_node.width / 2 + root_center_y = root_node.y + root_node.height / 2 + self._log(f" Move after root node center: ({root_center_x:.0f}, {root_center_y:.0f})", "DEBUG") + + # 重新计算所有节点的边界 + min_x = min(node.x for node in self.nodes) + max_x = max(node.x + node.width for node in self.nodes) + min_y = min(node.y for node in self.nodes) + max_y = max(node.y + node.height for node in self.nodes) + + # 设置滚动区域包含所有节点,但不以根节点为中心 + margin = 200 + scroll_region = (min_x - margin, min_y - margin, + max_x + margin, max_y + margin) + self.configure(scrollregion=scroll_region) + + self._log(f" Scroll region: ({scroll_region[0]:.0f}, {scroll_region[1]:.0f}) to ({scroll_region[2]:.0f}, {scroll_region[3]:.0f})", "DEBUG") + + # 简化滚动逻辑:直接让根节点在画布中显示在正确位置 + # 计算根节点在滚动区域中的相对位置 + scroll_width = (max_x + margin) - (min_x - margin) + scroll_height = (max_y + margin) - (min_y - margin) + + if scroll_width > 0: + # 让根节点的X位置对应到画布中心X + target_scroll_left = root_center_x - canvas_width / 2 + scroll_x = (target_scroll_left - (min_x - margin)) / scroll_width + scroll_x = max(0.0, min(1.0, scroll_x)) + else: + scroll_x = 0.5 + + if scroll_height > 0: + # 让根节点的Y位置对应到画布的45%位置(靠上) + target_scroll_top = root_center_y - canvas_height * 0.45 + scroll_y = (target_scroll_top - (min_y - margin)) / scroll_height + scroll_y = max(0.0, min(1.0, scroll_y)) + else: + scroll_y = 0.5 + + self._log(f" Target scroll position to display root node at canvas ({canvas_width/2:.0f}, {canvas_height*0.45:.0f})", "DEBUG") + self._log(f" Scroll ratio: ({scroll_x:.3f}, {scroll_y:.3f})", "DEBUG") + + # 应用滚动位置 + self.xview_moveto(scroll_x) + self.yview_moveto(scroll_y) + + # 重绘所有节点到新位置 + self.redraw_all() + + self._log("[LAYOUT] Dynamic adaptation completed - nodes moved to center axis", "DEBUG") + + + def fit_all_nodes(self, event=None): + """手动触发适应所有节点功能(快捷键:Ctrl+0 或 Home)""" + self._ultra_simple_center() + return "break" + + def emergency_reset_view(self, event=None): + """紧急重置视图到原点(快捷键:F5)""" + self._log("[EMERGENCY] Resetting view to origin", "DEBUG") + # 重置滚动位置到 (0, 0) + self.xview_moveto(0.0) + self.yview_moveto(0.0) + + # 重新计算滚动区域 + if self.nodes: + min_x = min(node.x for node in self.nodes) - 100 + max_x = max(node.x + node.width for node in self.nodes) + 100 + min_y = min(node.y for node in self.nodes) - 100 + max_y = max(node.y + node.height for node in self.nodes) + 100 + self.configure(scrollregion=(min_x, min_y, max_x, max_y)) + self._log(f"[SCROLL] Reset scroll region: ({min_x:.0f}, {min_y:.0f}) to ({max_x:.0f}, {max_y:.0f})", "DEBUG") + + return "break" + + def _set_dark_title_bar(self, window): + """设置窗口的深色标题栏(Windows 10/11)""" + try: + import ctypes + from ctypes import wintypes + + # 获取窗口句柄 + hwnd = window.winfo_id() + + # Windows 10/11 深色标题栏设置 + DWMWA_USE_IMMERSIVE_DARK_MODE = 20 + value = wintypes.DWORD(1) # 1 表示启用深色模式 + + ctypes.windll.dwmapi.DwmSetWindowAttribute( + hwnd, + DWMWA_USE_IMMERSIVE_DARK_MODE, + ctypes.byref(value), + ctypes.sizeof(value) + ) + except Exception as e: + self._log(f"Failed to set dark title bar: {e}", "DEBUG") + + def rename_selected_node(self, event=None): + """F2快捷键重命名选中节点""" + if self.selected_node: + # TaskFolder 根节点不允许重命名 + if self.selected_node.name == "TaskFolder": + self._log("TaskFolder root node cannot be renamed", "WARNING") + return "break" + self.rename_node() + return "break" + + + def _auto_layout_nodes(self): + """自动布局节点,使其排列整齐""" + if not self.nodes: + return + + # 从根节点开始布局 + start_x = 50 + start_y = 50 + x_spacing = 180 # 水平间距 + y_spacing = 100 # 垂直间距 + + for root_node in self.nodes: + if root_node.parent_id is None: + root_node.x = start_x + root_node.y = start_y + # 递归布局子节点 + self._layout_children(root_node, start_x, start_y + y_spacing, x_spacing, y_spacing) + + def _layout_children(self, parent_node, start_x, start_y, x_spacing, y_spacing): + """递归布局子节点""" + if not parent_node.children: + return + + # 计算子节点的总宽度 + total_width = len(parent_node.children) * x_spacing + # 起始 x 位置(居中对齐父节点) + current_x = parent_node.x - (total_width - x_spacing) / 2 + + for i, child in enumerate(parent_node.children): + child.x = current_x + i * x_spacing + child.y = start_y + + # 检查并避免重叠 + self._avoid_overlap(child) + + # 递归布局子节点的子节点 + self._layout_children(child, child.x, start_y + y_spacing, x_spacing, y_spacing) + + def _avoid_overlap(self, node: Node, max_attempts: int = 10): + """检查并避免节点重叠 + + Args: + node: 要检查的节点 + max_attempts: 最大尝试次数 + """ + margin = 20 # 节点之间的最小间距 + + for attempt in range(max_attempts): + overlapping = False + + for other in self.nodes: + if other.id == node.id: + continue + + # 检查是否重叠(包含边距) + if (abs(node.x - other.x) < node.width + margin and + abs(node.y - other.y) < node.height + margin): + overlapping = True + + # 计算避让方向 + dx = node.x - other.x + dy = node.y - other.y + + # 根据重叠方向移动节点 + if abs(dx) > abs(dy): + # 水平方向重叠更多 + if dx > 0: + node.x = other.x + other.width + margin + else: + node.x = other.x - node.width - margin + else: + # 垂直方向重叠更多 + if dy > 0: + node.y = other.y + other.height + margin + else: + node.y = other.y - node.height - margin + + break + + if not overlapping: + break + + def _draw_node_children_connections(self, node): + """递归绘制节点及其子节点的连接""" + for child in node.children: + self.draw_connection(node, child) + self._draw_node_children_connections(child) + + def copy_selected(self, event=None): + """Copy the selected nodes to the clipboard.""" + if not self.selected_nodes: + self._log("No nodes selected", "WARNING") + return + + # 只复制节点本身的数据,不包括子节点 + def copy_node_data(node): + """复制节点数据(不包括子节点)""" + node_data = { + 'name': node.name, + 'width': node.width, + 'height': node.height, + 'children': [] # 不复制子节点 + } + return node_data + + # 复制所有选中的节点 + NodeEditor._clipboard = [copy_node_data(node) for node in self.selected_nodes] + self._log(f"[CLIPBOARD] Copied {len(NodeEditor._clipboard)} nodes (excluding children)", "DEBUG") + + def paste_node(self, event=None): + """Paste nodes from the clipboard.""" + if not NodeEditor._clipboard: + self._log("Clipboard is empty", "WARNING") + return + + # 计算粘贴位置(在当前视口中心或鼠标位置) + paste_x = 400 + paste_y = 400 + + # 如果有选中的节点,在其旁边粘贴 + if self.selected_node: + paste_x = self.selected_node.x + 150 + paste_y = self.selected_node.y + 50 + + # 递归创建节点 + def create_node_from_data(node_data, parent_id=None, offset_x=0, offset_y=0): + """从数据创建节点""" + new_node = self.add_node( + node_data['name'], + paste_x + offset_x, + paste_y + offset_y, + parent_id + ) + + # 如果有父节点,建立连接 + if parent_id: + parent = next((n for n in self.nodes if n.id == parent_id), None) + if parent: + parent.children.append(new_node) + self.draw_connection(parent, new_node) + + # 递归创建子节点 + for i, child_data in enumerate(node_data['children']): + create_node_from_data( + child_data, + new_node.id, + offset_x + 150, + offset_y + i * 80 + ) + + return new_node + + # 清空当前选择 + self.selected_nodes = [] + + # 粘贴所有节点 + for i, node_data in enumerate(NodeEditor._clipboard): + new_node = create_node_from_data(node_data, None, 0, i * 80) + self.selected_nodes.append(new_node) + + self.selected_node = self.selected_nodes[0] if self.selected_nodes else None + self.redraw_all() + self._log(f"[CLIPBOARD] Pasted {len(self.selected_nodes)} nodes", "DEBUG") + + def duplicate_selected(self, event=None): + """Duplicate the selected node.""" + if not self.selected_node: + return + + # 创建新节点 + new_x = self.selected_node.x + 150 + new_y = self.selected_node.y + 50 + + # 复制节点 + new_node = self.add_node( + self.selected_node.name + " (Copy)", + new_x, + new_y, + self.selected_node.parent_id + ) + + # 如果有父节点,添加到父节点的子节点列表 + if self.selected_node.parent_id: + parent = next((n for n in self.nodes if n.id == self.selected_node.parent_id), None) + if parent: + parent.children.append(new_node) + self.draw_connection(parent, new_node) + + # 递归复制子节点 + def duplicate_children(original_node, new_parent_node): + for child in original_node.children: + child_x = new_parent_node.x + 150 + child_y = new_parent_node.y + 100 + new_child = self.add_node( + child.name, + child_x, + child_y, + new_parent_node.id + ) + new_parent_node.children.append(new_child) + self.draw_connection(new_parent_node, new_child) + duplicate_children(child, new_child) + + duplicate_children(self.selected_node, new_node) + + # 选中新节点 + self.selected_nodes = [new_node] + self.selected_node = new_node + self.redraw_all() diff --git a/ui/task/task_panel.py b/ui/task/task_panel.py new file mode 100644 index 0000000..f7b6031 --- /dev/null +++ b/ui/task/task_panel.py @@ -0,0 +1,2094 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +Task Panel Module +---------------- +A panel for creating and managing task folder structures with a node-based editor. +""" +import os +import traceback +import customtkinter as ctk +from tkinter import filedialog +import uuid +from typing import Dict, List, Optional, Any, Tuple +from .subfolder_editor import NodeEditor +from ..utilities.icon_utils import get_icon_path +from config.constants import ( + DEFAULT_WORKSPACE_PATH, + DEFAULT_MAYA_PLUGINS_PATH, + DEFAULT_SP_SHELF_PATH, + TASK_PANEL_BG_LIGHT, + TASK_PANEL_BG_DARK, + COLOR_SUCCESS, + COLOR_SUCCESS_HOVER, + COLOR_ERROR, + COLOR_ERROR_HOVER, + COLOR_WARNING, + DIALOG_BG_COLOR, + DIALOG_TEXT_COLOR, + BUTTON_GRAY, + BUTTON_GRAY_HOVER, + RESET_BUTTON_BORDER, + SAVE_BUTTON_BORDER, + DIALOG_MESSAGE_SIZE, + DIALOG_YES_NO_SIZE, + SUBFOLDER_EDITOR_WINDOW_SIZE, + SUBFOLDER_EDITOR_MIN_SIZE +) + + +def validate_folder_name(name: str) -> Tuple[bool, str]: + """验证文件夹名称是否安全 + + Args: + name: 文件夹名称 + + Returns: + (是否有效, 错误消息) + """ + # 检查空名称 + if not name or not name.strip(): + return False, "文件夹名称不能为空" + + # 检查非法字符 + invalid_chars = '<>:"|?*\\' + for char in invalid_chars: + if char in name: + return False, f"文件夹名称包含非法字符: {char}" + + # 检查路径遍历 + if '..' in name or name.startswith('/') or name.startswith('\\'): + return False, "文件夹名称不能包含路径遍历符号" + + # 检查保留名称(Windows) + reserved_names = ['CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', + 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', + 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', + 'LPT7', 'LPT8', 'LPT9'] + if name.upper() in reserved_names: + return False, f"'{name}' 是系统保留名称" + + # 检查长度 + if len(name) > 255: + return False, "文件夹名称过长(最多255字符)" + + return True, "" + + +def validate_path_safety(base_path: str, target_path: str) -> Tuple[bool, str]: + """验证目标路径是否在基础路径内 + + Args: + base_path: 基础路径 + target_path: 目标路径 + + Returns: + (是否安全, 错误消息) + """ + try: + # 获取绝对路径 + abs_base = os.path.abspath(base_path) + abs_target = os.path.abspath(target_path) + + # 解析符号链接 + real_base = os.path.realpath(abs_base) + real_target = os.path.realpath(abs_target) + + # 检查是否在基础路径内 + if not real_target.startswith(real_base): + return False, "目标路径超出允许范围" + + return True, "" + except Exception as e: + return False, f"路径验证失败: {str(e)}" + +class TaskPanel(ctk.CTkFrame): + """A panel for creating and managing task folder structures. + + This panel provides a user interface for creating tasks with customizable + folder structures using a node-based editor. + """ + + def __init__(self, parent, config_manager, **kwargs): + """Initialize the TaskPanel. + + Args: + parent: The parent widget + config_manager: The configuration manager instance + **kwargs: Additional keyword arguments for CTkFrame + """ + super().__init__(parent, **kwargs) + + self.config_manager = config_manager + self.current_project = self.config_manager.get_current_project() + # Folder templates and current structure are kept in-memory; persistence + # will be moved into the main config.json via ConfigManager. + self.folder_templates: Dict[str, Any] = {} + self.folder_structure: List[Dict[str, Any]] = [] + + # 调试模式控制 + self.debug_mode = False + + # 是否使用类型层级(默认不使用) + self.use_type_hierarchy = ctk.BooleanVar(value=False) + + # 存储主内容框架引用,用于更新颜色 + self.main_content_frame = None + + # Configure grid + self.grid_rowconfigure(0, weight=1) + self.grid_columnconfigure(0, weight=1) + + # Subfolder editor window reference + self.subfolder_editor_window = None + + # Create widgets + self._create_widgets() + + # Initialize structure with defaults before refresh so UI buttons work + self.setup_default_structure(self.task_type.get()) + + # Refresh to load any saved templates / project values. The actual + # node graph will be initialized when the SubFolder Editor window is + # opened. + self.refresh() + + def _log(self, message: str, level: str = "INFO"): + """统一的日志方法 + + Args: + message: 日志消息 + level: 日志级别 (DEBUG, INFO, WARNING, ERROR) + """ + # DEBUG级别的日志只在调试模式下输出 + if level == "DEBUG" and not self.debug_mode: + return + + prefix = f"[{level}]" + full_message = f"{prefix} {message}" + print(full_message) + + def _normalize_path(self, path: str) -> str: + """将路径标准化为反斜杠格式 + + Args: + path: 原始路径 + + Returns: + 标准化后的路径(使用反斜杠) + """ + if not path: + return path + # 将所有正斜杠统一为反斜杠,然后返回 + return path.replace("/", "\\") + + def _create_widgets(self): + """Create and layout the UI widgets.""" + # 创建主内容框架,带圆角和背景色(与 Project 面板一致) + self.main_content_frame = ctk.CTkFrame( + self, + corner_radius=10, # 与 Project 面板的 apps_outer_frame 保持一致 + fg_color=(TASK_PANEL_BG_LIGHT, TASK_PANEL_BG_DARK) # 与 Project 面板一致的颜色 + ) + self.main_content_frame.grid(row=0, column=0, padx=0, pady=0, sticky="nsew") + self.main_content_frame.grid_rowconfigure(1, weight=1) # middle_frame 可扩展 + self.main_content_frame.grid_rowconfigure(2, weight=0) # bottom_frame 固定高度 + self.main_content_frame.grid_columnconfigure(0, weight=1) + + # Top frame for controls + top_frame = ctk.CTkFrame(self.main_content_frame, fg_color="transparent") + top_frame.grid(row=0, column=0, padx=10, pady=10, sticky="ew") + # Make middle columns expand with window width + top_frame.grid_columnconfigure(0, weight=0) # labels + top_frame.grid_columnconfigure(1, weight=1) # main entries + top_frame.grid_columnconfigure(2, weight=0) # right-side button + + # Workspace selection + ctk.CTkLabel(top_frame, text="Workspace:").grid(row=0, column=0, padx=5, pady=5, sticky="w") + self.workspace_var = ctk.StringVar(value=os.path.join(DEFAULT_WORKSPACE_PATH, self.current_project)) + workspace_entry = ctk.CTkEntry(top_frame, textvariable=self.workspace_var) + workspace_entry.grid(row=0, column=1, padx=5, pady=5, sticky="ew") + workspace_entry.bind("", lambda e: self.validate_and_save_path(self.workspace_var, "Workspace")) + + browse_btn = ctk.CTkButton( + top_frame, + text="Browse...", + command=self.browse_workspace, + width=100, + ) + browse_btn.grid(row=0, column=2, padx=5, pady=5, sticky="e") + + # Maya Plugin Path + ctk.CTkLabel(top_frame, text="Maya Plugin Path:").grid(row=1, column=0, padx=5, pady=5, sticky="w") + self.maya_plugin_path_var = ctk.StringVar(value=DEFAULT_MAYA_PLUGINS_PATH) + maya_plugin_entry = ctk.CTkEntry(top_frame, textvariable=self.maya_plugin_path_var) + maya_plugin_entry.grid(row=1, column=1, padx=5, pady=5, sticky="ew") + maya_plugin_entry.bind("", lambda e: self.validate_and_save_path(self.maya_plugin_path_var, "Maya Plugin Path")) + + maya_plugin_browse_btn = ctk.CTkButton( + top_frame, + text="Browse...", + command=self.browse_maya_plugin_path, + width=100, + ) + maya_plugin_browse_btn.grid(row=1, column=2, padx=5, pady=5, sticky="e") + + # SP Shelf Path + ctk.CTkLabel(top_frame, text="SP Shelf Path:").grid(row=2, column=0, padx=5, pady=5, sticky="w") + self.sp_shelf_path_var = ctk.StringVar(value=DEFAULT_SP_SHELF_PATH) + sp_shelf_entry = ctk.CTkEntry(top_frame, textvariable=self.sp_shelf_path_var) + sp_shelf_entry.grid(row=2, column=1, padx=5, pady=5, sticky="ew") + sp_shelf_entry.bind("", lambda e: self.validate_and_save_path(self.sp_shelf_path_var, "SP Shelf Path")) + + sp_shelf_browse_btn = ctk.CTkButton( + top_frame, + text="Browse...", + command=self.browse_sp_shelf_path, + width=100, + ) + sp_shelf_browse_btn.grid(row=2, column=2, padx=5, pady=5, sticky="e") + + # Task type selection (从 config 动态读取) + ctk.CTkLabel(top_frame, text="Task Type:").grid(row=3, column=0, padx=5, pady=5, sticky="w") + + # 从 config_manager 获取任务类型列表 + task_types = self.config_manager.get_task_types() + + self.task_type = ctk.CTkComboBox( + top_frame, + values=task_types, + command=self.on_task_type_change, + width=150, + ) + self.task_type.grid(row=3, column=1, padx=5, pady=5, sticky="w") + + # 设置默认值(优先使用第一个,如果有 Character 则使用 Character) + if task_types: + default_type = "Character" if "Character" in task_types else task_types[0] + self.task_type.set(default_type) + + # 添加"使用类型层级"复选框(放在 Task Type 右侧) + self.use_hierarchy_checkbox = ctk.CTkCheckBox( + top_frame, + text="使用类型层级", + variable=self.use_type_hierarchy, + onvalue=True, + offvalue=False, + command=self.on_use_hierarchy_change + ) + self.use_hierarchy_checkbox.grid(row=3, column=2, padx=5, pady=5, sticky="w") + + # Task name (own row, full-width entry) + ctk.CTkLabel(top_frame, text="Task Name:").grid(row=4, column=0, padx=5, pady=(0, 5), sticky="w") + self.task_name = ctk.CTkEntry(top_frame) + self.task_name.grid(row=4, column=1, columnspan=2, padx=5, pady=(0, 5), sticky="ew") + + # SubFolder Editor button (new row, full-width) + self.subfolder_editor_button = ctk.CTkButton( + top_frame, + text="SubFolder Editor", + command=self.open_subfolder_editor, + ) + self.subfolder_editor_button.grid(row=5, column=0, columnspan=3, padx=5, pady=(0, 5), sticky="ew") + + # 中间空白区域(用于显示预览或其他内容) + middle_frame = ctk.CTkFrame(self.main_content_frame, fg_color="transparent") + middle_frame.grid(row=1, column=0, padx=10, pady=5, sticky="nsew") + + # Bottom frame for main-panel actions (Create Folder / Open Folder) + bottom_frame = ctk.CTkFrame(self.main_content_frame, fg_color="transparent") + bottom_frame.grid(row=2, column=0, padx=10, pady=(5, 10), sticky="ew") + bottom_frame.grid_columnconfigure(0, weight=1) + bottom_frame.grid_columnconfigure(1, weight=1) + + # Create Folder and Open Folder buttons share width equally and stretch with window + self.create_folder_button = ctk.CTkButton( + bottom_frame, + text="Create Folder", + command=self.create_task, + ) + self.create_folder_button.grid(row=0, column=0, padx=5, pady=5, sticky="ew") + + self.open_folder_button = ctk.CTkButton( + bottom_frame, + text="Open Folder", + command=self.open_task_folder, + ) + self.open_folder_button.grid(row=0, column=1, padx=5, pady=5, sticky="ew") + + def browse_workspace(self): + """Open a directory dialog to select the workspace.""" + # 从当前输入框的路径开始,如果路径不存在则使用父目录或用户目录 + current_path = self.workspace_var.get() + if current_path and os.path.exists(current_path): + initial_dir = current_path + elif current_path and os.path.exists(os.path.dirname(current_path)): + initial_dir = os.path.dirname(current_path) + else: + initial_dir = os.path.expanduser("~") + + workspace = filedialog.askdirectory( + initialdir=initial_dir, + title="Select Workspace Directory", + ) + if workspace: + self.workspace_var.set(self._normalize_path(workspace)) + self.save_task_settings() + + def browse_maya_plugin_path(self): + """Open a directory dialog to select the Maya Plugin Path.""" + # 从当前输入框的路径开始,如果路径不存在则使用父目录或用户目录 + current_path = self.maya_plugin_path_var.get() + if current_path and os.path.exists(current_path): + initial_dir = current_path + elif current_path and os.path.exists(os.path.dirname(current_path)): + initial_dir = os.path.dirname(current_path) + else: + initial_dir = os.path.expanduser("~") + + maya_plugin_path = filedialog.askdirectory( + initialdir=initial_dir, + title="Select Maya Plugin Path", + ) + if maya_plugin_path: + self.maya_plugin_path_var.set(self._normalize_path(maya_plugin_path)) + self.save_task_settings() + + def browse_sp_shelf_path(self): + """Open a directory dialog to select the SP Shelf Path.""" + # 从当前输入框的路径开始,如果路径不存在则使用父目录或用户目录 + current_path = self.sp_shelf_path_var.get() + if current_path and os.path.exists(current_path): + initial_dir = current_path + elif current_path and os.path.exists(os.path.dirname(current_path)): + initial_dir = os.path.dirname(current_path) + else: + initial_dir = os.path.expanduser("~") + + sp_shelf_path = filedialog.askdirectory( + initialdir=initial_dir, + title="Select SP Shelf Path", + ) + if sp_shelf_path: + self.sp_shelf_path_var.set(self._normalize_path(sp_shelf_path)) + self.save_task_settings() + + def open_subfolder_editor(self): + """Open the SubFolder Editor window that hosts the NodeEditor.""" + if self.subfolder_editor_window is None or not self.subfolder_editor_window.winfo_exists(): + self.subfolder_editor_window = SubFolderEditorWindow(self) + else: + self.subfolder_editor_window.focus() + + def open_task_folder(self): + """Open the currently configured task folder in the system file browser.""" + task_name = self.task_name.get().strip() + if not task_name: + self.show_error("Error", "Please enter a task name") + return + + workspace = self.workspace_var.get() + task_type = self.task_type.get() + + # 根据开关决定路径 + if self.use_type_hierarchy.get(): + task_root = os.path.join(workspace, task_type, task_name) + else: + task_root = os.path.join(workspace, task_name) + + if not os.path.isdir(task_root): + self.show_error("Error", f"Task folder does not exist: {task_root}") + return + + try: + os.startfile(task_root) + except Exception as e: + self.show_error("Error", f"Failed to open folder: {str(e)}") + + def on_task_type_change(self, choice): + """Handle task type change. + + Args: + choice: The selected task type + """ + # Update task name suggestion based on new task type set + current_name = self.task_name.get() + should_update = not current_name + + if current_name: + # 重新加载配置以获取最新的任务类型列表 + self.config_manager.reload_config() + all_types = self.config_manager.get_task_types() + + # 检查当前任务名是否以选择的类型开头 + is_current_type = current_name.startswith(f"{choice}_") + + # 如果不是当前选择的类型,则检查是否是任务类型前缀 + if not is_current_type: + # 检查是否以任何任务类型开头 + is_task_type_prefix = any(current_name.startswith(f"{t}_") for t in all_types) + + # 如果是任务类型前缀(旧类型或其他类型),则更新 + # 如果不是任务类型前缀(可能是旧的已删除的类型),也更新 + should_update = True + + # 调试输出 + self._log("Task Type Changed", "DEBUG") + self._log(f"Selected type: {choice}", "DEBUG") + self._log(f"Current task name: {current_name}", "DEBUG") + self._log(f"All types: {all_types}", "DEBUG") + self._log(f"Is current type: {is_current_type}", "DEBUG") + self._log(f"Should update: {should_update}", "DEBUG") + + if should_update: + new_name = f"{choice}_001" + self.task_name.delete(0, "end") + self.task_name.insert(0, new_name) + self._log(f"Task name updated: {new_name}", "INFO") + + # 从新类型的模板加载结构,并更新 SubFolders + self.update_subfolders_from_task_type(choice) + + if self.subfolder_editor_window and self.subfolder_editor_window.winfo_exists(): + # 取消之前的居中任务,避免冲突 + self.subfolder_editor_window.subfolder_editor._cancel_pending_center() + + self.subfolder_editor_window.subfolder_editor.load_structure(self.folder_structure) + # 更新 SubFolder Editor 窗口中的信息显示(如果方法存在) + if hasattr(self.subfolder_editor_window, 'update_info_labels'): + self.subfolder_editor_window.update_info_labels() + + # 保存设置到 config + self.save_task_settings() + + def on_use_hierarchy_change(self): + """处理"使用类型层级"开关变化""" + # 保存设置到 config + self.save_task_settings() + self._log(f"Use type hierarchy changed to: {self.use_type_hierarchy.get()}", "INFO") + + def setup_default_structure(self, task_type: Optional[str] = None): + """Set up the default folder structure. + + The defaults are aligned with the legacy Maya taskbuild script. + """ + self.folder_structure = [] + + # 优先从 config 的 task_settings.SubFolders 读取 + current_project = self.current_project + project_data = self.config_manager.config_data.get("projects", {}).get(current_project, {}) + task_settings = project_data.get("task_settings", {}) + paths = task_settings.get("SubFolders") + + # 如果没有 SubFolders,则按 TaskType 查询 + if not paths: + current_task_type = task_type or self.task_type.get() + paths = self.config_manager.get_task_folder_template(current_task_type) + self._log(f"Using TaskType '{current_task_type}' default template", "DEBUG") + else: + self._log(f"Using project '{current_project}' SubFolders config", "DEBUG") + + # 使用 _convert_paths_to_structure 来正确计算节点位置 + if paths: + self.folder_structure = self._convert_paths_to_structure(paths) + else: + # 如果没有路径,创建空的根节点 + self.folder_structure = [{ + "id": str(uuid.uuid4()), + "name": "TaskFolder", + "parent_id": None, + "x": 500, + "y": 400, + "width": 120, + "height": 60, + "children": [], + }] + + if self.subfolder_editor_window and self.subfolder_editor_window.winfo_exists(): + self.subfolder_editor_window.subfolder_editor.load_structure(self.folder_structure) + + def update_subfolders_from_task_type(self, task_type: str): + """根据任务类型更新 SubFolders 配置 + + Args: + task_type: 任务类型名称 + """ + # 从任务类型模板获取路径列表 + paths = self.config_manager.get_task_folder_template(task_type) + + if paths: + # 更新内存中的结构 + self.folder_structure = self._convert_paths_to_structure(paths) + + # 更新 config 中的 task_settings.SubFolders + current_project = self.current_project + if not self.config_manager.config_data.get("projects"): + self.config_manager.config_data["projects"] = {} + + if current_project not in self.config_manager.config_data["projects"]: + self.config_manager.config_data["projects"][current_project] = {} + + # 确保 task_settings 存在 + if "task_settings" not in self.config_manager.config_data["projects"][current_project]: + self.config_manager.config_data["projects"][current_project]["task_settings"] = {} + + # 保存到 task_settings.SubFolders 字段 + self.config_manager.config_data["projects"][current_project]["task_settings"]["SubFolders"] = paths + + # 保存配置文件 + if self.config_manager.save_config(): + self._log(f"Updated SubFolders to '{task_type}' template", "INFO") + self._log(f" Path count: {len(paths)}", "DEBUG") + else: + self._log("Failed to save SubFolders", "ERROR") + else: + self._log(f"No template for task type '{task_type}'", "WARNING") + + def create_task(self): + """Create the task folder structure on disk.""" + task_name = self.task_name.get().strip() + + # 验证任务名称 + is_valid, error_msg = validate_folder_name(task_name) + if not is_valid: + self.show_error("错误", error_msg) + return + + # 保存当前设置到 config + self.save_task_settings() + + workspace = self.workspace_var.get() + if not workspace: + self.show_error("错误", "请选择工作空间路径") + return + + # 检查工作空间路径是否有效 + try: + # 尝试获取绝对路径 + workspace = os.path.abspath(workspace) + + # 检查路径是否在合理范围内(例如不在系统目录) + system_dirs = [ + os.environ.get('SYSTEMROOT', 'C:\\Windows'), + os.environ.get('PROGRAMFILES', 'C:\\Program Files'), + os.environ.get('PROGRAMFILES(X86)', 'C:\\Program Files (x86)') + ] + + for sys_dir in system_dirs: + if workspace.startswith(sys_dir): + self.show_error( + "错误", + f"不允许在系统目录创建任务文件夹:\n{workspace}\n\n请选择其他位置。" + ) + return + + self._log(f"Workspace path: {workspace}", "INFO") + except Exception as e: + self.show_error("错误", f"工作空间路径无效: {str(e)}") + return + + if not os.path.isdir(workspace): + try: + self._log(f"Creating workspace directory: {workspace}", "INFO") + os.makedirs(workspace, exist_ok=True) + self._log("Workspace created successfully", "INFO") + except PermissionError: + self.show_error("权限错误", f"没有权限在此位置创建文件夹:\n{workspace}\n\n请选择其他位置或以管理员身份运行程序") + return + except OSError as e: + self.show_error("路径错误", f"无法创建工作空间:\n{workspace}\n\n错误: {str(e)}") + return + except Exception as e: + self.show_error("错误", f"创建工作空间失败: {str(e)}") + return + + # Get the folder structure from the node editor + structure = self.folder_structure or [] + + # Task root: 根据开关决定是否包含类型层级 + task_type = self.task_type.get() + if self.use_type_hierarchy.get(): + # 使用类型层级: Workspace / TaskType / TaskName + task_root = os.path.join(workspace, task_type, task_name) + else: + # 不使用类型层级: Workspace / TaskName + task_root = os.path.join(workspace, task_name) + + # 验证最终路径安全性 + is_safe, error_msg = validate_path_safety(workspace, task_root) + if not is_safe: + self.show_error("安全错误", f"路径验证失败:\n{error_msg}") + return + + # 检查任务文件夹是否已存在 + folder_existed = os.path.exists(task_root) + + # 检查文件夹结构是否为空 + if not structure: + self.show_error("错误", "没有可创建的文件夹结构\n请先在SubFolder Editor中设计文件夹结构") + return + + # Create the folder structure under the task root + try: + self._log(f"Creating task root: {task_root}", "INFO") + + # 创建根目录(如果不存在) + try: + os.makedirs(task_root, exist_ok=True) + self._log(f"Task root created/verified: {task_root}", "INFO") + except PermissionError: + self.show_error("权限错误", f"没有权限创建任务文件夹:\n{task_root}\n\n请选择其他位置或以管理员身份运行程序") + return + except OSError as e: + self.show_error("路径错误", f"无法创建任务文件夹:\n{task_root}\n\n错误: {str(e)}") + return + + # 创建子文件夹结构(跳过已存在的,只创建缺失的) + self._log(f"Creating subfolders, structure count: {len(structure)}", "INFO") + created_count, skipped_count = self._create_folders(task_root, structure) + self._log(f"Folder creation completed: {created_count} created, {skipped_count} skipped", "INFO") + + # 根据情况显示不同的消息 + if folder_existed: + if created_count > 0: + self.show_success("提示", f"该文件夹已存在\n已补全 {created_count} 个缺失的文件夹\n跳过 {skipped_count} 个已存在的文件夹") + else: + self.show_success("提示", f"该文件夹已存在\n所有文件夹都已存在,无需创建") + else: + self.show_success("成功", f"任务文件夹创建成功!\n创建了 {created_count} 个文件夹") + + # 自动打开任务文件夹 + try: + self._log(f"Opening folder: {task_root}", "INFO") + os.startfile(task_root) + except Exception as e: + self._log(f"Failed to open folder: {e}", "WARNING") + + except PermissionError as e: + self.show_error("权限错误", f"没有权限创建文件夹\n请以管理员身份运行程序或选择其他位置\n\n详细错误: {str(e)}") + except OSError as e: + self.show_error("系统错误", f"文件系统错误\n可能是磁盘空间不足或路径问题\n\n详细错误: {str(e)}") + except Exception as e: + self.show_error("创建失败", f"创建任务文件夹时发生未知错误\n\n详细错误: {str(e)}\n\n请检查:\n1. 路径是否有效\n2. 是否有足够权限\n3. 磁盘空间是否充足") + + def _create_folders(self, base_path: str, nodes: List[Dict[str, Any]], parent_path: str = "") -> tuple: + """Recursively create folders based on the node structure. + + Args: + base_path: The base path where folders should be created + nodes: List of node dictionaries + parent_path: Current parent path for recursive calls + + Returns: + Tuple of (created_count, skipped_count) + """ + created_count = 0 + skipped_count = 0 + + for node in nodes: + # Skip the root TaskFolder node when creating directories + if node['name'] == 'TaskFolder' and not parent_path: + child_created, child_skipped = self._create_folders(base_path, node.get('children', []), parent_path) + created_count += child_created + skipped_count += child_skipped + continue + + # Create the directory path + dir_name = node['name'] + dir_path = os.path.join(base_path, parent_path, dir_name) + + try: + # 检查文件夹是否已存在 + if os.path.exists(dir_path): + self._log(f"Already exists: {dir_path}", "DEBUG") + skipped_count += 1 + else: + os.makedirs(dir_path, exist_ok=True) + self._log(f"Directory: {dir_path}", "INFO") + created_count += 1 + except PermissionError as e: + self._log(f"Permission denied: {dir_path}", "ERROR") + raise PermissionError(f"没有权限创建文件夹: {dir_path}") + except OSError as e: + self._log(f"OS error creating directory {dir_path}: {e}", "ERROR") + raise OSError(f"系统错误,无法创建文件夹 {dir_path}: {str(e)}") + except Exception as e: + self._log(f"Unexpected error creating directory {dir_path}: {e}", "ERROR") + raise Exception(f"创建文件夹时发生未知错误 {dir_path}: {str(e)}") + + # Recursively create child directories + if 'children' in node and node['children']: + child_path = os.path.join(parent_path, dir_name) if parent_path else dir_name + child_created, child_skipped = self._create_folders(base_path, node['children'], child_path) + created_count += child_created + skipped_count += child_skipped + + return created_count, skipped_count + + def save_template(self): + """Save the current folder structure as a template to config.json.""" + task_type = self.task_type.get() + if not task_type: + self.show_error("Error", "Please select a task type") + return + + # Get the current structure from subfolder_editor_window if open, otherwise from folder_structure + if self.subfolder_editor_window and self.subfolder_editor_window.winfo_exists(): + structure = self.subfolder_editor_window.subfolder_editor.get_structure() + else: + structure = self.folder_structure + + if not structure: + self.show_error("Error", "No folder structure to save") + return + + # Convert node structure to folder path list + folder_paths = self._convert_structure_to_paths(structure) + + # Save to config_manager + if self.config_manager.set_task_folder_template(task_type, folder_paths): + self.show_success("Success", f"Template saved for '{task_type}'") + + # 保存成功后,重新从 config 加载该类型的模板,确保数据同步 + self.setup_default_structure(task_type) + self._log(f"Refreshed {task_type} folder structure", "INFO") + else: + self.show_error("Error", "Failed to save template") + + def load_template(self): + """Load a saved template from config.json.""" + task_type = self.task_type.get() + if not task_type: + self.show_error("Error", "Please select a task type") + return + + # 使用 setup_default_structure 从 config 加载最新模板 + self.setup_default_structure(task_type) + + # Update subfolder_editor if it exists + if self.subfolder_editor_window and self.subfolder_editor_window.winfo_exists(): + self.subfolder_editor_window.subfolder_editor.load_structure(self.folder_structure) + + self.show_success("Success", f"Template loaded for '{task_type}' from config") + self._log(f"Loaded {task_type} template from config", "INFO") + + def _convert_structure_to_paths(self, structure: List[Dict[str, Any]]) -> List[str]: + """Convert node structure to flat folder path list. + + Args: + structure: Node structure from subfolder_editor + + Returns: + List of folder paths (e.g., ['Brief', 'Baking/HP', 'Texture/SP']) + """ + paths = [] + + def traverse(nodes, parent_path=""): + for node in nodes: + # Skip root TaskFolder node + if node['name'] == 'TaskFolder' and not parent_path: + traverse(node.get('children', []), parent_path) + continue + + # Build current path using / as separator + current_path = f"{parent_path}/{node['name']}" if parent_path else node['name'] + paths.append(current_path) + + # Traverse children + if 'children' in node and node['children']: + traverse(node['children'], current_path) + + traverse(structure) + return paths + + def _convert_paths_to_structure(self, paths: List[str]) -> List[Dict[str, Any]]: + """Convert flat folder path list to node structure. + + Args: + paths: List of folder paths (e.g., ['Brief', 'MP/Screenshot']) + + Returns: + Node structure for subfolder_editor + """ + def new_node(name: str, x: int, y: int, parent_id: Optional[str]) -> Dict[str, Any]: + return { + "id": str(uuid.uuid4()), + "name": name, + "parent_id": parent_id, + "x": x, + "y": y, + "width": 120, + "height": 60, + "children": [], + } + + root_node = new_node("TaskFolder", 500, 50, None) + node_map = {"TaskFolder": root_node} + + # 第一步:构建树结构 + for path in paths: + # 支持 \\ 和 / 作为分隔符,统一转换为 / + parts = path.replace("\\", "/").split("/") + parent_key = "TaskFolder" + + for i, part in enumerate(parts): + current_key = "/".join(parts[:i+1]) + + if current_key not in node_map: + # 创建节点(位置稍后计算) + parent_node = node_map[parent_key] + new_child = new_node(part, 0, 0, parent_node["id"]) + parent_node["children"].append(new_child) + node_map[current_key] = new_child + + parent_key = current_key + + # 第二步:按层级收集所有节点 + levels = {} # {depth: [nodes]} + + def collect_by_level(node, depth=0): + """按层级收集节点""" + if depth not in levels: + levels[depth] = [] + levels[depth].append(node) + + for child in node["children"]: + collect_by_level(child, depth + 1) + + collect_by_level(root_node) + + # 第三步:为每层的节点分配位置(横向布局) + self._log("Node Layout", "DEBUG") + + # 计算最佳布局参数 + max_nodes_in_layer = max(len(nodes) for nodes in levels.values()) + + # 动态调整间距,确保内容适应合理的宽度 + # 目标宽度:尽量不超过1000像素(更保守的宽度) + target_max_width = 1000 + min_spacing = 130 # 减少最小间距,节点宽度是120,留10像素间隙 + max_spacing = 200 # 减少最大间距 + + if max_nodes_in_layer > 1: + calculated_spacing = target_max_width / (max_nodes_in_layer - 1) + horizontal_spacing = max(min_spacing, min(calculated_spacing, max_spacing)) + else: + horizontal_spacing = min_spacing + + # 使用相对中心位置,让居中逻辑来处理实际的画布居中 + # 使用一个相对居中的坐标,避免硬编码偏移 + canvas_center_x = 600 # 标准中心X坐标 (将由居中算法调整) + canvas_center_y = 250 # 标准Y坐标,给顶部留些空间 + + self._log(f"Max nodes in layer: {max_nodes_in_layer}, spacing: {horizontal_spacing:.0f}", "DEBUG") + + for depth, nodes in levels.items(): + self._log(f"Level {depth}: {len(nodes)} nodes", "DEBUG") + + if depth == 0: + # 根节点放在画布中心 + nodes[0]["x"] = canvas_center_x + nodes[0]["y"] = canvas_center_y + self._log(f" {nodes[0]['name']}: ({nodes[0]['x']}, {nodes[0]['y']})", "DEBUG") + else: + # Y: 根据层级(垂直向下) + y = canvas_center_y + depth * 150 # 减少垂直间距 + + # X: 在该层内水平均匀分布 + node_count = len(nodes) + + # 计算该层的起始X坐标(居中对齐根节点) + total_width = (node_count - 1) * horizontal_spacing + start_x = canvas_center_x - total_width / 2 + + for idx, node in enumerate(nodes): + node["x"] = start_x + idx * horizontal_spacing + node["y"] = y + self._log(f" {node['name']}: ({node['x']}, {node['y']})", "DEBUG") + + return [root_node] + + def load_folder_templates(self) -> Dict[str, Any]: + """Load folder templates from config_manager.""" + return self.config_manager.get_all_task_folder_templates() + + def save_folder_templates(self): + """Save folder templates to config_manager.""" + pass # No need to implement this method as config_manager handles it + + + def _log(self, message: str, level: str = "INFO"): + """统一的日志方法 + + Args: + message: 日志消息 + level: 日志级别 (DEBUG, INFO, WARNING, ERROR) + """ + # DEBUG级别的日志只在调试模式下输出 + if level == "DEBUG" and not self.debug_mode: + return + + prefix = f"[{level}]" + full_message = f"{prefix} {message}" + print(full_message) + + def _set_dialog_icon(self, dialog): + """为对话框设置 NexusLauncher 图标(统一方法) + + Args: + dialog: CTkToplevel 对话框实例 + """ + icon_path = get_icon_path() + if os.path.exists(icon_path): + try: + # 使用多种方法设置图标 + dialog.iconbitmap(icon_path) + dialog.iconbitmap(default=icon_path) + + # 尝试使用 wm_iconbitmap + try: + dialog.wm_iconbitmap(icon_path) + except: + pass + + # 多次延迟设置,防止被覆盖 + def set_icon(): + try: + dialog.iconbitmap(icon_path) + dialog.iconbitmap(default=icon_path) + dialog.wm_iconbitmap(icon_path) + except: + pass + + dialog.after(10, set_icon) + dialog.after(50, set_icon) + dialog.after(100, set_icon) + dialog.after(200, set_icon) + dialog.after(500, set_icon) + except Exception as e: + self._log(f"Failed to set dialog icon: {e}", "WARNING") + + def show_error(self, title: str, message: str): + """Show an error message with dark theme. + + Args: + title: The title of the error message + message: The error message + """ + self._show_custom_dialog(title, message, "error") + + def show_success(self, title: str, message: str): + """Show a success message with dark theme. + + Args: + title: The title of the success message + message: The success message + """ + self._show_custom_dialog(title, message, "success") + + def _show_custom_dialog(self, title: str, message: str, dialog_type: str = "info"): + """显示自定义深色对话框 + + Args: + title: 对话框标题 + message: 对话框消息 + dialog_type: 对话框类型 ("success", "error", "info") + """ + # 创建顶层窗口 + dialog = ctk.CTkToplevel(self) + dialog.title(title) + dialog.geometry(DIALOG_MESSAGE_SIZE) # 增加高度确保按钮可见 + dialog.resizable(False, False) + + # 设置窗口图标(使用统一方法) + self._set_dialog_icon(dialog) + + # 设置为模态 + dialog.transient(self) + dialog.grab_set() + + # 居中显示 + dialog.update_idletasks() + x = (dialog.winfo_screenwidth() - 400) // 2 + y = (dialog.winfo_screenheight() - 250) // 2 + dialog.geometry(f"{DIALOG_MESSAGE_SIZE}+{x}+{y}") + + # 根据类型选择图标和颜色 + if dialog_type == "success": + icon = "[OK]" + icon_color = COLOR_SUCCESS + elif dialog_type == "error": + icon = "[ERROR]" + icon_color = COLOR_ERROR + else: + icon = "ℹ" + icon_color = COLOR_WARNING + + # 主框架 + main_frame = ctk.CTkFrame(dialog, fg_color=DIALOG_BG_COLOR) + main_frame.pack(fill="both", expand=True, padx=0, pady=0) + + # 图标和消息框架 + content_frame = ctk.CTkFrame(main_frame, fg_color="transparent") + content_frame.pack(fill="both", expand=True, padx=20, pady=20) + + # 图标 + icon_label = ctk.CTkLabel( + content_frame, + text=icon, + font=("Segoe UI", 40, "bold"), + text_color=icon_color + ) + icon_label.pack(pady=(10, 10)) + + # 消息 + message_label = ctk.CTkLabel( + content_frame, + text=message, + font=("Segoe UI", 12), + text_color=DIALOG_TEXT_COLOR, + wraplength=350 + ) + message_label.pack(pady=(0, 20)) + + # 确定按钮 + ok_button = ctk.CTkButton( + main_frame, + text="确定", + command=dialog.destroy, + fg_color=icon_color, + hover_color=self._darken_color(icon_color), + font=("Segoe UI", 12, "bold"), + width=100, + height=35, + corner_radius=8 + ) + ok_button.pack(pady=(0, 20)) + + # 绑定 Enter 和 Escape 键 + dialog.bind("", lambda e: dialog.destroy()) + dialog.bind("", lambda e: dialog.destroy()) + + # 等待窗口关闭 + dialog.wait_window() + + def _show_yes_no_dialog(self, title: str, message: str) -> bool: + """显示 Yes/No 确认对话框 + + Args: + title: 对话框标题 + message: 对话框消息 + + Returns: + True 表示用户点击 Yes,False 表示点击 No + """ + result = [False] # 使用列表来存储结果,以便在嵌套函数中修改 + + # 创建顶层窗口 + dialog = ctk.CTkToplevel(self) + dialog.title(title) + dialog.geometry(DIALOG_YES_NO_SIZE) + dialog.resizable(False, False) + + # 设置窗口图标(使用统一方法) + self._set_dialog_icon(dialog) + + # 设置为模态 + dialog.transient(self) + dialog.grab_set() + + # 居中显示 + dialog.update_idletasks() + x = (dialog.winfo_screenwidth() - 450) // 2 + y = (dialog.winfo_screenheight() - 250) // 2 + dialog.geometry(f"{DIALOG_YES_NO_SIZE}+{x}+{y}") + + # 主框架 + main_frame = ctk.CTkFrame(dialog, fg_color=DIALOG_BG_COLOR) + main_frame.pack(fill="both", expand=True, padx=0, pady=0) + + # 图标和消息框架 + content_frame = ctk.CTkFrame(main_frame, fg_color="transparent") + content_frame.pack(fill="both", expand=True, padx=20, pady=20) + + # 警告图标 + icon_label = ctk.CTkLabel( + content_frame, + text="[WARNING]", + font=("Segoe UI", 40, "bold"), + text_color=COLOR_WARNING + ) + icon_label.pack(pady=(10, 10)) + + # 消息 + message_label = ctk.CTkLabel( + content_frame, + text=message, + font=("Segoe UI", 11), + text_color=DIALOG_TEXT_COLOR, + wraplength=400, + justify="left" + ) + message_label.pack(pady=(0, 20)) + + # 按钮框架 + button_frame = ctk.CTkFrame(main_frame, fg_color="transparent") + button_frame.pack(pady=(0, 20)) + + def on_yes(): + result[0] = True + dialog.destroy() + + def on_no(): + result[0] = False + dialog.destroy() + + # Yes 按钮 + yes_button = ctk.CTkButton( + button_frame, + text="是 (Y)", + command=on_yes, + fg_color=COLOR_SUCCESS, + hover_color=COLOR_SUCCESS_HOVER, + font=("Segoe UI", 12, "bold"), + width=100, + height=35, + corner_radius=8 + ) + yes_button.pack(side="left", padx=10) + + # No 按钮 + no_button = ctk.CTkButton( + button_frame, + text="否 (N)", + command=on_no, + fg_color=COLOR_ERROR, + hover_color=COLOR_ERROR_HOVER, + font=("Segoe UI", 12, "bold"), + width=100, + height=35, + corner_radius=8 + ) + no_button.pack(side="left", padx=10) + + # 绑定快捷键 + dialog.bind("", lambda e: on_yes()) + dialog.bind("y", lambda e: on_yes()) + dialog.bind("Y", lambda e: on_yes()) + dialog.bind("", lambda e: on_no()) + dialog.bind("n", lambda e: on_no()) + dialog.bind("N", lambda e: on_no()) + + # 等待窗口关闭 + dialog.wait_window() + + return result[0] + + def _darken_color(self, hex_color: str, factor: float = 0.8) -> str: + """将颜色变暗 + + Args: + hex_color: 十六进制颜色代码 + factor: 变暗因子 (0-1) + + Returns: + 变暗后的颜色 + """ + # 移除 # 号 + hex_color = hex_color.lstrip('#') + + # 转换为 RGB + r = int(hex_color[0:2], 16) + g = int(hex_color[2:4], 16) + b = int(hex_color[4:6], 16) + + # 变暗 + r = int(r * factor) + g = int(g * factor) + b = int(b * factor) + + # 转换回十六进制 + return f"#{r:02x}{g:02x}{b:02x}" + + def refresh(self): + """Refresh the panel with the current project settings.""" + self.current_project = self.config_manager.get_current_project() + + # 刷新任务类型列表(从 config 读取最新的类型) + self.refresh_task_types() + + # 从 config 加载项目的 Task 设置 + task_settings = self.config_manager.get_task_settings(self.current_project) + + # 更新 Workspace + workspace = task_settings.get("workspace", DEFAULT_WORKSPACE_PATH) + self.workspace_var.set(self._normalize_path(workspace)) + + # 更新 Task Type + task_type = task_settings.get("task_type", "Character") + self.task_type.set(task_type) + + # 更新"使用类型层级"开关 + use_hierarchy = task_settings.get("use_type_hierarchy", False) + self.use_type_hierarchy.set(use_hierarchy) + + # 更新 Maya Plugin Path + maya_plugin_path = task_settings.get("maya_plugin_path", DEFAULT_MAYA_PLUGINS_PATH) + self.maya_plugin_path_var.set(self._normalize_path(maya_plugin_path)) + + # 更新 SP Shelf Path + sp_shelf_path = task_settings.get("sp_shelf_path", DEFAULT_SP_SHELF_PATH) + self.sp_shelf_path_var.set(self._normalize_path(sp_shelf_path)) + + # Reload templates + self.folder_templates = self.load_folder_templates() + + # 加载当前任务类型的默认结构 + self.setup_default_structure(task_type) + + # 如果项目没有 SubFolders,从任务类型模板加载 + if "SubFolders" not in task_settings or not task_settings["SubFolders"]: + # 从任务类型模板获取路径列表并保存到 SubFolders + template_paths = self.config_manager.get_task_folder_template(task_type) + if template_paths: + # 直接更新到 config 中 + if not self.config_manager.config_data.get("projects"): + self.config_manager.config_data["projects"] = {} + if self.current_project not in self.config_manager.config_data["projects"]: + self.config_manager.config_data["projects"][self.current_project] = {} + if "task_settings" not in self.config_manager.config_data["projects"][self.current_project]: + self.config_manager.config_data["projects"][self.current_project]["task_settings"] = {} + + self.config_manager.config_data["projects"][self.current_project]["task_settings"]["SubFolders"] = template_paths + + # 自动保存当前设置到 config(类似任务类型切换时的行为) + self.save_task_settings() + + # Update task name suggestion based on current task type + # 检查当前任务名是否以任何任务类型开头 + current_name = self.task_name.get() + should_update = not current_name + if current_name: + # 获取所有任务类型 + all_types = self.config_manager.get_task_types() + # 检查是否以任何类型开头 + should_update = any(current_name.startswith(f"{t}_") for t in all_types) + + if should_update: + self.task_name.delete(0, "end") + self.task_name.insert(0, f"{task_type}_001") + + # 不在这里触碰 subfolder_editor,实际结构加载在 SubFolderEditorWindow 打开时处理 + + def refresh_task_types(self): + """刷新任务类型列表(从 config 读取)""" + try: + # 重新加载 config 数据 + self.config_manager.reload_config() + + # 获取最新的任务类型列表 + task_types = self.config_manager.get_task_types() + + # 更新 ComboBox 的选项 + current_value = self.task_type.get() + self.task_type.configure(values=task_types) + + # 如果当前值仍然在列表中,保持不变;否则设置为第一个 + if current_value in task_types: + self.task_type.set(current_value) + elif task_types: + self.task_type.set(task_types[0]) + except Exception as e: + self._log(f"Failed to refresh task type list: {e}", "ERROR") + + def validate_and_save_path(self, path_var, path_name): + """验证路径是否存在,如果不存在询问是否创建 + + Args: + path_var: StringVar 对象 + path_name: 路径名称(用于显示) + """ + path = path_var.get().strip() + if not path: + self.save_task_settings() + return + + # 检查路径是否存在 + if not os.path.exists(path): + # 弹窗询问是否创建 + from ..utilities import custom_dialogs + result = custom_dialogs.ask_yes_no( + self, + "路径不存在", + f"{path_name} 路径不存在:\n{path}\n\n是否创建该路径?" + ) + + if result: # 用户选择"是" + try: + os.makedirs(path, exist_ok=True) + self._log(f"Created directory: {path}", "INFO") + custom_dialogs.show_info(self, "成功", f"已创建路径:\n{path}") + except Exception as e: + self._log(f"Failed to create directory {path}: {e}", "ERROR") + custom_dialogs.show_error(self, "错误", f"创建路径失败:\n{str(e)}") + return + + # 保存设置 + self.save_task_settings() + + def save_task_settings(self): + """保存当前 Task 设置到 config""" + self.config_manager.set_task_settings( + self.current_project, + workspace=self.workspace_var.get(), + task_type=self.task_type.get(), + use_type_hierarchy=self.use_type_hierarchy.get(), + maya_plugin_path=self.maya_plugin_path_var.get(), + sp_shelf_path=self.sp_shelf_path_var.get() + ) + + def update_colors(self, bg_color: str = None): + """更新面板的背景颜色 + + Args: + bg_color: 背景颜色,如果为 None 则使用默认颜色 + """ + if self.main_content_frame: + if bg_color: + self.main_content_frame.configure(fg_color=bg_color) + else: + # 使用默认颜色(与 Project 面板一致) + self.main_content_frame.configure(fg_color=(TASK_PANEL_BG_LIGHT, TASK_PANEL_BG_DARK)) + + +class SubFolderEditorWindow(ctk.CTkToplevel): + """Separate window for editing subfolder structures with the NodeEditor. + + Layout参考设置窗口:上面是信息区,下面是节点编辑器和操作按钮。 + """ + + def __init__(self, parent: "TaskPanel"): + super().__init__(parent) + + self.parent_panel = parent + self.debug_mode = False # 调试模式控制 + + # Local NodeEditor instance used only inside this window. We sync its + # structure with the TaskPanel so data is shared, not the widget. + self.subfolder_editor = NodeEditor(self) + + # 窗口大小变化防抖 + self._resize_after_id = None + + self.title("SubFolder Editor") + self.geometry(SUBFOLDER_EDITOR_WINDOW_SIZE) # 增加高度确保按钮可见 + self.minsize(*SUBFOLDER_EDITOR_MIN_SIZE) + + # 绑定快捷键 + self.bind("", lambda e: self.save_structure()) + self.bind("", lambda e: self.subfolder_editor.force_recenter()) + + # 绑定窗口大小变化事件 + self.bind("", self._on_window_resize) + + self._log("Bound shortcuts: Ctrl+S (Save), Ctrl+0 (Center)", "INFO") + self._log("Bound window resize listener", "INFO") + + # 先隐藏窗口,避免加载时闪烁 + self.withdraw() + + # 设置窗口图标(在transient之前设置) + icon_path = get_icon_path() + if os.path.exists(icon_path): + try: + # 尝试多种方法设置图标 + self.iconbitmap(icon_path) + self.wm_iconbitmap(icon_path) + except Exception as e: + self._log(f"Failed to set icon: {e}", "DEBUG") + + # 先设置为瞬态窗口 + self.transient(parent) + + # 等待窗口创建完成 + self.update_idletasks() + + # 再次尝试设置图标(确保生效) + if os.path.exists(icon_path): + def set_icon_delayed(): + try: + self.iconbitmap(icon_path) + self.wm_iconbitmap(icon_path) + except Exception: + pass + self.after(50, set_icon_delayed) + self.after(200, set_icon_delayed) + + # 总体布局 + self.grid_rowconfigure(0, weight=0) # 顶部信息区固定 + self.grid_rowconfigure(1, weight=1) # 编辑器区域可扩展 + self.grid_columnconfigure(0, weight=1) + + # 顶部信息区 + info_frame = ctk.CTkFrame(self) + info_frame.grid(row=0, column=0, padx=10, pady=10, sticky="ew") + info_frame.grid_columnconfigure(0, weight=0) # 标签列固定 + info_frame.grid_columnconfigure(1, weight=1) # 内容列可扩展 + info_frame.grid_columnconfigure(2, weight=0) # 按钮列固定 + + ctk.CTkLabel(info_frame, text="Workspace:").grid(row=0, column=0, padx=5, pady=5, sticky="e") + self.workspace_label = ctk.CTkLabel(info_frame, text=self.parent_panel.workspace_var.get(), anchor="w") + self.workspace_label.grid(row=0, column=1, padx=5, pady=5, sticky="ew") + + ctk.CTkLabel(info_frame, text="Task Type:").grid(row=1, column=0, padx=5, pady=5, sticky="e") + self.task_type_label = ctk.CTkLabel(info_frame, text=self.parent_panel.task_type.get(), anchor="w") + self.task_type_label.grid(row=1, column=1, padx=5, pady=5, sticky="ew") + + ctk.CTkLabel(info_frame, text="Task Name:").grid(row=2, column=0, padx=5, pady=5, sticky="e") + self.task_name_label = ctk.CTkLabel(info_frame, text=self.parent_panel.task_name.get(), anchor="w") + self.task_name_label.grid(row=2, column=1, padx=5, pady=5, sticky="ew") + + # 中部:NodeEditor + 滚动条 + editor_outer = ctk.CTkFrame(self) + editor_outer.grid(row=1, column=0, padx=10, pady=(0, 10), sticky="nsew") + editor_outer.grid_rowconfigure(0, weight=1) + editor_outer.grid_columnconfigure(0, weight=1) + + # 创建滚动条 + scrollbar_y = ctk.CTkScrollbar(editor_outer, orientation="vertical") + scrollbar_x = ctk.CTkScrollbar(editor_outer, orientation="horizontal") + + # 配置 NodeEditor 的滚动 + self.subfolder_editor.configure( + yscrollcommand=scrollbar_y.set, + xscrollcommand=scrollbar_x.set + ) + scrollbar_y.configure(command=self.subfolder_editor.yview) + scrollbar_x.configure(command=self.subfolder_editor.xview) + + # 初始滚动区域将由居中逻辑动态设置 + # self.subfolder_editor.configure(scrollregion=(0, 0, 2000, 2000)) + + # 布局 + self.subfolder_editor.grid(row=0, column=0, sticky="nsew") + scrollbar_y.grid(row=0, column=1, sticky="ns") + scrollbar_x.grid(row=1, column=0, sticky="ew") + + # 创建悬浮重置按钮(左下角) + self.floating_reset_btn = ctk.CTkButton( + self, + text="🔄 Reset", + command=self.reset_to_template, + fg_color=BUTTON_GRAY, + hover_color=BUTTON_GRAY_HOVER, + font=("Segoe UI", 14, "bold"), + width=120, + height=50, + corner_radius=25, # 大圆角 + border_width=2, + border_color=RESET_BUTTON_BORDER, + ) + # 使用 place 定位在左下角 + self.floating_reset_btn.place(relx=0.05, rely=0.95, anchor="sw") + + # 创建悬浮保存按钮(右下角) + self.floating_save_btn = ctk.CTkButton( + self, + text="💾 Save", + command=self.save_structure, + fg_color=COLOR_SUCCESS, + hover_color=COLOR_SUCCESS_HOVER, + font=("Segoe UI", 14, "bold"), + width=120, + height=50, + corner_radius=25, # 大圆角 + border_width=2, + border_color=SAVE_BUTTON_BORDER, + ) + # 使用 place 定位在右下角 + self.floating_save_btn.place(relx=0.95, rely=0.95, anchor="se") + + self._log("Created floating reset button (bottom left)", "INFO") + self._log("Created floating save button (bottom right)", "INFO") + + # 在打开时根据当前 Task Type 加载默认结构 + try: + current_task_type = self.parent_panel.task_type.get() + current_project = self.parent_panel.current_project + + # 检查 config 中是否有 SubFolders + project_data = self.parent_panel.config_manager.config_data.get("projects", {}).get(current_project, {}) + task_settings = project_data.get("task_settings", {}) + subfolders = task_settings.get("SubFolders") + + if not subfolders or len(subfolders) == 0: + # SubFolders 为空或不存在,从 task_folder_templates 加载 + self._log(f"SubFolders is empty or not found, loading from task_folder_templates", "INFO") + self._log(f"Current task type: {current_task_type}", "INFO") + + template_paths = self.parent_panel.config_manager.get_task_folder_template(current_task_type) + + if template_paths: + self._log(f"Found template for '{current_task_type}' with {len(template_paths)} paths", "INFO") + # 转换为节点结构并加载 + self.parent_panel.folder_structure = self.parent_panel._convert_paths_to_structure(template_paths) + self.subfolder_editor.load_structure(self.parent_panel.folder_structure) + self._log(f"Loaded template structure for {current_task_type}", "INFO") + else: + self._log(f"No template found for task type '{current_task_type}'", "WARNING") + # 使用默认结构 + self.parent_panel.setup_default_structure(current_task_type) + if self.parent_panel.folder_structure: + self.subfolder_editor.load_structure(self.parent_panel.folder_structure) + else: + # SubFolders 存在,使用现有配置 + self._log(f"Loading from existing SubFolders ({len(subfolders)} paths)", "INFO") + self.parent_panel.setup_default_structure(current_task_type) + + if self.parent_panel.folder_structure: + self.subfolder_editor.load_structure(self.parent_panel.folder_structure) + self._log(f"Loaded {current_task_type} folder structure", "INFO") + else: + self._log(f"Warning: No default structure for {current_task_type}", "WARNING") + except Exception as e: + self._log(f"Failed to load structure: {e}", "ERROR") + traceback.print_exc() + + # 所有内容加载完成后,强制更新布局 + self.update_idletasks() + + # 居中显示窗口并抓取焦点 + self._center_window() + + # 再次更新确保按钮可见 + self.update_idletasks() + + self.deiconify() + # 移除模态设置,允许用户与主窗口交互 + # self.grab_set() # 注释掉模态设置 + self.focus_set() + + self._log(f"SubFolder Editor window shown, size: {self.winfo_width()}x{self.winfo_height()}", "INFO") + + # 设置深色标题栏 + self.after(10, lambda: self._set_dark_title_bar(self)) + + # 设置窗口关闭时的处理 + self.protocol("WM_DELETE_WINDOW", self._on_closing) + + # 启动定时同步 + self.after(500, self._sync_labels) + + # 延迟检查按钮可见性 + self.after(200, self._check_save_button) + + + def _log(self, message: str, level: str = "INFO"): + """统一的日志方法 + + Args: + message: 日志消息 + level: 日志级别 (DEBUG, INFO, WARNING, ERROR) + """ + # DEBUG级别的日志只在调试模式下输出 + if level == "DEBUG" and not self.debug_mode: + return + + prefix = f"[{level}]" + full_message = f"{prefix} {message}" + print(full_message) + + def _set_dialog_icon(self, dialog): + """为对话框设置 NexusLauncher 图标(统一方法) + + Args: + dialog: CTkToplevel 对话框实例 + """ + icon_path = get_icon_path() + if os.path.exists(icon_path): + try: + # 使用多种方法设置图标 + dialog.iconbitmap(icon_path) + dialog.iconbitmap(default=icon_path) + + # 尝试使用 wm_iconbitmap + try: + dialog.wm_iconbitmap(icon_path) + except: + pass + + # 多次延迟设置,防止被覆盖 + def set_icon(): + try: + dialog.iconbitmap(icon_path) + dialog.iconbitmap(default=icon_path) + dialog.wm_iconbitmap(icon_path) + except: + pass + + dialog.after(10, set_icon) + dialog.after(50, set_icon) + dialog.after(100, set_icon) + dialog.after(200, set_icon) + dialog.after(500, set_icon) + except Exception as e: + self._log(f"Failed to set dialog icon: {e}", "WARNING") + + def reset_to_template(self): + """从配置文件重新加载当前任务类型的模板""" + try: + current_task_type = self.parent_panel.task_type.get() + + if not current_task_type: + self.parent_panel.show_error("重置失败", "请先选择任务类型") + return + + # 显示确认对话框 + confirm_msg = ( + f"确定要重置为 {current_task_type} 类型的默认模板吗?\n\n" + f"当前的所有修改将会丢失!" + ) + + result = self._show_yes_no_dialog("确认重置", confirm_msg) + if not result: + self._log("User cancelled reset", "ERROR") + return + + self._log("Reset to Template", "DEBUG") + self._log(f"Task type: {current_task_type}", "DEBUG") + + # 根据当前 TaskType 从 task_folder_templates 加载对应模板 + folder_paths = self.parent_panel.config_manager.get_task_folder_template(current_task_type) + + if folder_paths: + self._log(f"Loaded from task_folder_templates.{current_task_type}", "INFO") + self._log(f" Template paths: {folder_paths}", "DEBUG") + + # 转换为节点结构 + structure = self.parent_panel._convert_paths_to_structure(folder_paths) + self.parent_panel.folder_structure = structure + else: + self._log("No template found for {current_task_type}, using default", "WARNING") + # 使用默认模板 + self.parent_panel.setup_default_structure(current_task_type) + + if self.parent_panel.folder_structure: + # 清空当前编辑器 + self.subfolder_editor.nodes.clear() + self.subfolder_editor.connections.clear() + self.subfolder_editor.selected_nodes.clear() + self.subfolder_editor.selected_connections.clear() + + # 加载模板结构 + self.subfolder_editor.load_structure(self.parent_panel.folder_structure) + + self._log("Reset to {current_task_type} template", "INFO") + self.parent_panel.show_success( + "重置成功", + f"已重置为 {current_task_type} 类型的默认模板" + ) + else: + self._log("No template found", "ERROR") + self.parent_panel.show_error("重置失败", f"未找到 {current_task_type} 的模板配置") + + except Exception as e: + self._log(f"Reset failed: {e}", "ERROR") + traceback.print_exc() + self.parent_panel.show_error("重置失败", f"发生错误: {str(e)}") + + def save_structure(self): + """保存当前节点结构到 config 文件的 SubFolders 字段""" + try: + # 获取当前结构 + structure = self.subfolder_editor.get_structure() + if not structure: + self._log("No structure to save", "ERROR") + self.parent_panel.show_error("保存失败", "没有可保存的文件夹结构") + return + + # 检测未连接的节点 + unconnected_nodes = self._find_unconnected_nodes() + if unconnected_nodes: + node_names = ", ".join([n.name for n in unconnected_nodes]) + warning_msg = ( + f"检测到 {len(unconnected_nodes)} 个未连接的节点:\n\n" + f"{node_names}\n\n" + f"这些节点不会被保存到配置文件。\n" + f"是否继续保存?" + ) + + result = self._show_yes_no_dialog("未连接节点警告", warning_msg) + if not result: + self._log("User cancelled save", "ERROR") + return + + self._log("Ignoring {len(unconnected_nodes)} unconnected nodes", "WARNING") + + # 转换为路径列表 + folder_paths = self.parent_panel._convert_structure_to_paths(structure) + + if not folder_paths: + self._log("Converted path list is empty", "ERROR") + self.parent_panel.show_error("保存失败", "文件夹结构为空") + return + + # 获取当前项目和任务类型 + current_project = self.parent_panel.current_project + task_type = self.parent_panel.task_type.get() + + self._log("Save SubFolders to Config", "DEBUG") + self._log(f"Project: {current_project}", "DEBUG") + self._log(f"Task type: {task_type}", "DEBUG") + self._log(f"Folder count: {len(folder_paths)}", "DEBUG") + self._log("Folder list:", "DEBUG") + for i, path in enumerate(folder_paths, 1): + self._log(f" {i}. {path}", "DEBUG") + + # 保存到 config 的 task_settings.SubFolders 字段 + if not self.parent_panel.config_manager.config_data.get("projects"): + self.parent_panel.config_manager.config_data["projects"] = {} + + if current_project not in self.parent_panel.config_manager.config_data["projects"]: + self.parent_panel.config_manager.config_data["projects"][current_project] = {} + + # 确保 task_settings 存在 + if "task_settings" not in self.parent_panel.config_manager.config_data["projects"][current_project]: + self.parent_panel.config_manager.config_data["projects"][current_project]["task_settings"] = {} + + # 保存到 task_settings.SubFolders 字段 + self.parent_panel.config_manager.config_data["projects"][current_project]["task_settings"]["SubFolders"] = folder_paths + + # 保存配置文件 + if self.parent_panel.config_manager.save_config(): + self._log("Successfully saved to config.json", "INFO") + self._log(" Field: projects.{current_project}.task_settings.SubFolders", "DEBUG") + self._log(f" Path count: {len(folder_paths)}", "DEBUG") + + # 同步到 TaskPanel + self.parent_panel.folder_structure = structure + + # 显示成功提示 + self.parent_panel.show_success( + "保存成功", + f"已保存 {len(folder_paths)} 个文件夹到配置文件\n\n" + f"项目: {current_project}\n" + f"字段: task_settings.SubFolders" + ) + else: + self._log("Failed to save config file", "ERROR") + self.parent_panel.show_error("保存失败", "无法写入配置文件") + except Exception as e: + self._log(f"Failed to save structure: {e}", "ERROR") + traceback.print_exc() + self.parent_panel.show_error("保存失败", f"发生错误: {str(e)}") + + def _show_yes_no_dialog(self, title: str, message: str) -> bool: + """显示 Yes/No 确认对话框 + + Args: + title: 对话框标题 + message: 对话框消息 + + Returns: + True 表示用户点击 Yes,False 表示点击 No + """ + result = [False] # 使用列表来存储结果,以便在嵌套函数中修改 + + # 创建顶层窗口 + dialog = ctk.CTkToplevel(self) + dialog.title(title) + dialog.geometry(DIALOG_YES_NO_SIZE) + dialog.resizable(False, False) + + # 设置窗口图标(使用统一方法) + self._set_dialog_icon(dialog) + + # 设置为模态 + dialog.transient(self) + dialog.grab_set() + + # 居中显示 + dialog.update_idletasks() + x = (dialog.winfo_screenwidth() - 450) // 2 + y = (dialog.winfo_screenheight() - 250) // 2 + dialog.geometry(f"{DIALOG_YES_NO_SIZE}+{x}+{y}") + + # 主框架 + main_frame = ctk.CTkFrame(dialog, fg_color=DIALOG_BG_COLOR) + main_frame.pack(fill="both", expand=True, padx=0, pady=0) + + # 图标和消息框架 + content_frame = ctk.CTkFrame(main_frame, fg_color="transparent") + content_frame.pack(fill="both", expand=True, padx=20, pady=20) + + # 警告图标 + icon_label = ctk.CTkLabel( + content_frame, + text="[WARNING]", + font=("Segoe UI", 40, "bold"), + text_color=COLOR_WARNING + ) + icon_label.pack(pady=(10, 10)) + + # 消息 + message_label = ctk.CTkLabel( + content_frame, + text=message, + font=("Segoe UI", 11), + text_color=DIALOG_TEXT_COLOR, + wraplength=400, + justify="left" + ) + message_label.pack(pady=(0, 20)) + + # 按钮框架 + button_frame = ctk.CTkFrame(main_frame, fg_color="transparent") + button_frame.pack(pady=(0, 20)) + + def on_yes(): + result[0] = True + dialog.destroy() + + def on_no(): + result[0] = False + dialog.destroy() + + # Yes 按钮 + yes_button = ctk.CTkButton( + button_frame, + text="是 (Y)", + command=on_yes, + fg_color=COLOR_SUCCESS, + hover_color=COLOR_SUCCESS_HOVER, + font=("Segoe UI", 12, "bold"), + width=100, + height=35, + corner_radius=8 + ) + yes_button.pack(side="left", padx=10) + + # No 按钮 + no_button = ctk.CTkButton( + button_frame, + text="否 (N)", + command=on_no, + fg_color=COLOR_ERROR, + hover_color=COLOR_ERROR_HOVER, + font=("Segoe UI", 12, "bold"), + width=100, + height=35, + corner_radius=8 + ) + no_button.pack(side="left", padx=10) + + # 绑定快捷键 + dialog.bind("", lambda e: on_yes()) + dialog.bind("y", lambda e: on_yes()) + dialog.bind("Y", lambda e: on_yes()) + dialog.bind("", lambda e: on_no()) + dialog.bind("n", lambda e: on_no()) + dialog.bind("N", lambda e: on_no()) + + # 等待窗口关闭 + dialog.wait_window() + + return result[0] + + def _find_unconnected_nodes(self): + """查找所有未连接到 TaskFolder 的节点""" + unconnected = [] + + # 找到根节点 + root = next((n for n in self.subfolder_editor.nodes if n.name == "TaskFolder"), None) + if not root: + return unconnected + + # 使用 BFS 找到所有连接的节点 + connected = set() + queue = [root] + + while queue: + node = queue.pop(0) + connected.add(node.id) + for child in node.children: + if child.id not in connected: + queue.append(child) + + # 找出未连接的节点 + for node in self.subfolder_editor.nodes: + if node.id not in connected and node.name != "TaskFolder": + unconnected.append(node) + + return unconnected + + def destroy(self): + """销毁窗口前解除事件绑定,避免潜在泄漏""" + try: + # 解除快捷键绑定 + self.unbind("") + self.unbind("") + self.unbind("") + except Exception as e: + self._log(f"Failed to unbind events on destroy: {e}", "DEBUG") + # 调用父类销毁 + return super().destroy() + + def _on_closing(self): + """窗口关闭时的处理 - 自动保存到 config""" + try: + self._log("Closing SubFolder Editor", "DEBUG") + + # 获取当前结构 + structure = self.subfolder_editor.get_structure() + if structure: + # 同步到 TaskPanel + self.parent_panel.folder_structure = structure + self._log("Synced structure to TaskPanel", "INFO") + + # 自动保存到 config + self._log("[LOADING] Auto-saving to config...", "DEBUG") + self.save_structure() + else: + self._log("No structure to save", "WARNING") + except Exception as e: + self._log(f"Failed to save on close: {e}", "ERROR") + traceback.print_exc() + + # 销毁窗口 + self.destroy() + + def _check_save_button(self): + """检查悬浮保存按钮是否可见""" + try: + if hasattr(self, 'floating_save_btn'): + exists = self.floating_save_btn.winfo_exists() + visible = self.floating_save_btn.winfo_viewable() + mapped = self.floating_save_btn.winfo_ismapped() + width = self.floating_save_btn.winfo_width() + height = self.floating_save_btn.winfo_height() + x = self.floating_save_btn.winfo_x() + y = self.floating_save_btn.winfo_y() + + self._log("Floating Save Button Check", "DEBUG") + self._log(f"Exists: {exists}", "DEBUG") + self._log(f"Visible: {visible}", "DEBUG") + self._log(f"Mapped: {mapped}", "DEBUG") + self._log(f"Size: {width}x{height}", "DEBUG") + self._log("Position: ({x}, {y})", "DEBUG") + self._log(f"Window size: {self.winfo_width()}x{self.winfo_height()}", "DEBUG") + + if not visible or not mapped: + self._log("Warning: Floating button not visible! Trying to fix...", "WARNING") + self.floating_save_btn.lift() + self.floating_save_btn.update() + self._log("Attempted to lift button layer", "INFO") + else: + self._log("Floating save button displaying normally", "INFO") + else: + self._log("Error: floating_save_btn attribute does not exist", "ERROR") + except Exception as e: + self._log(f"Failed to check button: {e}", "ERROR") + traceback.print_exc() + + def _sync_labels(self): + """Sync labels with parent TaskPanel values while window exists.""" + if not self.winfo_exists(): + return + + try: + self.workspace_label.configure(text=self.parent_panel.workspace_var.get()) + self.task_type_label.configure(text=self.parent_panel.task_type.get()) + self.task_name_label.configure(text=self.parent_panel.task_name.get()) + except Exception: + pass + + # 同步结构回 TaskPanel 的 folder_structure + try: + structure = self.subfolder_editor.get_structure() + if structure: + self.parent_panel.folder_structure = structure + except Exception: + pass + + self.after(500, self._sync_labels) + + def _center_window(self): + """将窗口居中显示在屏幕中央""" + self.update_idletasks() + + # 获取窗口大小 + window_width = self.winfo_width() + window_height = self.winfo_height() + + # 获取屏幕大小 + screen_width = self.winfo_screenwidth() + screen_height = self.winfo_screenheight() + + # 计算居中位置 + x = (screen_width - window_width) // 2 + y = (screen_height - window_height) // 2 + + # 设置窗口位置 + self.geometry(f"{window_width}x{window_height}+{x}+{y}") + + def _set_dark_title_bar(self, window): + """设置窗口深色标题栏(Windows 10/11)""" + try: + import ctypes + window.update() + hwnd = ctypes.windll.user32.GetParent(window.winfo_id()) + + # DWMWA_USE_IMMERSIVE_DARK_MODE = 20 (Windows 11) + # DWMWA_USE_IMMERSIVE_DARK_MODE = 19 (Windows 10 1903+) + DWMWA_USE_IMMERSIVE_DARK_MODE = 20 + value = ctypes.c_int(1) # 1 = 深色模式 + + ctypes.windll.dwmapi.DwmSetWindowAttribute( + hwnd, + DWMWA_USE_IMMERSIVE_DARK_MODE, + ctypes.byref(value), + ctypes.sizeof(value) + ) + + # 如果 Windows 11 方式失败,尝试 Windows 10 方式 + if ctypes.windll.dwmapi.DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, ctypes.byref(value), ctypes.sizeof(value)) != 0: + DWMWA_USE_IMMERSIVE_DARK_MODE = 19 + ctypes.windll.dwmapi.DwmSetWindowAttribute( + hwnd, + DWMWA_USE_IMMERSIVE_DARK_MODE, + ctypes.byref(value), + ctypes.sizeof(value) + ) + except Exception as e: + self._log(f"Failed to set dark title bar: {e}", "DEBUG") + + def _on_window_resize(self, event): + """处理窗口大小变化事件""" + # 只处理窗口本身的大小变化,忽略子组件的变化 + if event.widget != self: + return + + # 获取新的窗口尺寸 + new_width = event.width + new_height = event.height + + # 避免频繁触发,使用防抖机制 + if hasattr(self, '_resize_after_id') and self._resize_after_id: + self.after_cancel(self._resize_after_id) + + # 延迟执行重新居中,避免在拖拽过程中频繁计算 + self._resize_after_id = self.after(500, lambda: self._handle_window_resize(new_width, new_height)) + + def _handle_window_resize(self, width, height): + """处理窗口大小变化后的重新布局""" + self._log(f"[RESIZE] Window size changed: {width}x{height}", "DEBUG") + + # 清理防抖ID + self._resize_after_id = None + + # 如果有节点,重新计算居中 + if hasattr(self.subfolder_editor, 'nodes') and self.subfolder_editor.nodes: + self._log(" Recalculating center layout...", "DEBUG") + + # 使用超简单居中方法,避免复杂计算 + self.subfolder_editor._ultra_simple_center() diff --git a/ui/utilities/__init__.py b/ui/utilities/__init__.py new file mode 100644 index 0000000..abb4beb --- /dev/null +++ b/ui/utilities/__init__.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +UI Utilities Package +------------------- +UI工具包,包含对话框、图标管理、工具函数等 +""" + +from .custom_dialogs import ( + MessageDialog, + InputDialog, + show_info, + show_warning, + show_error, + ask_yes_no, + ask_string +) + +from .base_dialog import BaseDialog + +# 图标工具 +from .icon_utils import ( + get_icons_dir, + get_icon_path, + set_window_icon, + setup_dialog_icon +) + +# 窗口工具 +from .window_utils import ( + set_dark_title_bar, + setup_dialog_window +) + +# 颜色工具 +from .color_utils import ( + darken_color, + lighten_color, + hex_to_rgb, + rgb_to_hex +) + +# 向后兼容已迁移到具体模块,不再需要utils.py + +from .icon_manager import IconManager +from .window_manager import WindowManager +from .ui_helpers import UIHelpers + +__all__ = [ + # 对话框类 + 'MessageDialog', + 'InputDialog', + 'BaseDialog', + + # 对话框便捷函数 + 'show_info', + 'show_warning', + 'show_error', + 'ask_yes_no', + 'ask_string', + + # 图标工具函数 + 'get_icons_dir', + 'get_icon_path', + 'set_window_icon', + 'setup_dialog_icon', + + # 窗口工具函数 + 'set_dark_title_bar', + 'setup_dialog_window', + + # 颜色工具函数 + 'darken_color', + 'lighten_color', + 'hex_to_rgb', + 'rgb_to_hex', + + # 管理器类 + 'IconManager', + 'WindowManager', + 'UIHelpers' +] diff --git a/ui/utilities/base_dialog.py b/ui/utilities/base_dialog.py new file mode 100644 index 0000000..0c792ad --- /dev/null +++ b/ui/utilities/base_dialog.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +对话框基类 +""" +import tkinter as tk +from .icon_utils import get_icon_path +from config.constants import BG_COLOR_DARK +import os + + +class BaseDialog(tk.Toplevel): + """对话框基类,提供通用的初始化和居中功能""" + + def __init__(self, parent, title: str, width: int, height: int): + """ + 初始化对话框 + + Args: + parent: 父窗口 + title: 窗口标题 + width: 窗口宽度 + height: 窗口高度 + """ + super().__init__(parent) + + # 先隐藏窗口 + self.withdraw() + + # 设置窗口属性 + self.title(title) + self.geometry(f"{width}x{height}") + self.resizable(False, False) + + # 设置图标 + icon_path = get_icon_path() + if os.path.exists(icon_path): + self.iconbitmap(icon_path) + + # 设置深色主题背景 + self.configure(bg=BG_COLOR_DARK) + + # 设置为模态窗口 + self.transient(parent) + + # 居中显示 + self._center_window() + + def _center_window(self): + """将窗口居中显示""" + self.update_idletasks() + + screen_width = self.winfo_screenwidth() + screen_height = self.winfo_screenheight() + window_width = self.winfo_width() + window_height = self.winfo_height() + + x = (screen_width - window_width) // 2 + y = (screen_height - window_height) // 2 + + self.geometry(f"+{x}+{y}") + + def show(self): + """显示对话框""" + self.deiconify() + self.grab_set() + self.wait_window() diff --git a/ui/utilities/color_utils.py b/ui/utilities/color_utils.py new file mode 100644 index 0000000..bcf462f --- /dev/null +++ b/ui/utilities/color_utils.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +颜色工具模块 +负责颜色处理和转换 +""" + + +def darken_color(hex_color: str, factor: float = 0.2) -> str: + """使颜色变暗 + + Args: + hex_color: 十六进制颜色代码 + factor: 变暗系数,0-1 之间 + + Returns: + 变暗后的十六进制颜色代码 + """ + # 去除#前缀 + hex_color = hex_color.lstrip('#') + + # 转换为RGB + r = int(hex_color[0:2], 16) + g = int(hex_color[2:4], 16) + b = int(hex_color[4:6], 16) + + # 降低亮度 + r = max(0, int(r * (1 - factor))) + g = max(0, int(g * (1 - factor))) + b = max(0, int(b * (1 - factor))) + + # 转回十六进制 + return f'#{r:02x}{g:02x}{b:02x}' + + +def lighten_color(hex_color: str, factor: float = 0.2) -> str: + """使颜色变亮 + + Args: + hex_color: 十六进制颜色代码 + factor: 变亮系数,0-1 之间 + + Returns: + 变亮后的十六进制颜色代码 + """ + # 去除#前缀 + hex_color = hex_color.lstrip('#') + + # 转换为RGB + r = int(hex_color[0:2], 16) + g = int(hex_color[2:4], 16) + b = int(hex_color[4:6], 16) + + # 提高亮度 + r = min(255, int(r + (255 - r) * factor)) + g = min(255, int(g + (255 - g) * factor)) + b = min(255, int(b + (255 - b) * factor)) + + # 转回十六进制 + return f'#{r:02x}{g:02x}{b:02x}' + + +def hex_to_rgb(hex_color: str) -> tuple: + """将十六进制颜色转换为RGB元组 + + Args: + hex_color: 十六进制颜色代码 + + Returns: + RGB颜色元组 (r, g, b) + """ + hex_color = hex_color.lstrip('#') + return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) + + +def rgb_to_hex(r: int, g: int, b: int) -> str: + """将RGB颜色转换为十六进制 + + Args: + r: 红色分量 (0-255) + g: 绿色分量 (0-255) + b: 蓝色分量 (0-255) + + Returns: + 十六进制颜色代码 + """ + return f'#{r:02x}{g:02x}{b:02x}' diff --git a/ui/utilities/custom_dialogs.py b/ui/utilities/custom_dialogs.py new file mode 100644 index 0000000..bb5346d --- /dev/null +++ b/ui/utilities/custom_dialogs.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +自定义对话框 +提供与应用主题统一的消息对话框 +""" +import customtkinter as ctk +from typing import Optional +from .base_dialog import BaseDialog +from config.constants import ( + BG_COLOR_DARK, + BUTTON_WIDTH_MEDIUM, + BUTTON_HEIGHT_SMALL, + FONT_SIZE_TINY, + FONT_SIZE_MEDIUM, + FONT_SIZE_LARGE +) + + +class MessageDialog(BaseDialog): + """自定义消息对话框""" + + def __init__(self, parent, title: str, message: str, dialog_type: str = "info", **kwargs): + """ + 初始化消息对话框 + + Args: + parent: 父窗口 + title: 对话框标题 + message: 消息内容 + dialog_type: 对话框类型 (info, warning, error, question) + """ + super().__init__(parent, title, 400, 200) + + self.result = None + self.dialog_type = dialog_type + + # 创建界面 + self._create_widgets(message) + + # 显示对话框 + self.show() + + def _create_widgets(self, message: str): + """创建界面组件""" + # 主容器 + main_frame = ctk.CTkFrame(self, fg_color=BG_COLOR_DARK) + main_frame.pack(fill="both", expand=True, padx=20, pady=20) + + # 图标和消息容器 + content_frame = ctk.CTkFrame(main_frame, fg_color="transparent") + content_frame.pack(fill="both", expand=True) + + # 图标 + icon_text = self._get_icon_text() + icon_color = self._get_icon_color() + + icon_label = ctk.CTkLabel( + content_frame, + text=icon_text, + font=ctk.CTkFont(size=40), + text_color=icon_color, + width=60 + ) + icon_label.pack(side="left", padx=(0, 15)) + + # 消息文本 + message_label = ctk.CTkLabel( + content_frame, + text=message, + font=ctk.CTkFont(size=FONT_SIZE_LARGE), + wraplength=280, + justify="left" + ) + message_label.pack(side="left", fill="both", expand=True) + + # 按钮容器 + button_frame = ctk.CTkFrame(main_frame, fg_color="transparent") + button_frame.pack(side="bottom", pady=(20, 0)) + + # 根据对话框类型创建按钮 + if self.dialog_type == "question": + # 是/否按钮 + no_btn = ctk.CTkButton( + button_frame, + text="否", + command=self._on_no, + width=BUTTON_WIDTH_MEDIUM, + height=BUTTON_HEIGHT_SMALL, + font=ctk.CTkFont(size=FONT_SIZE_MEDIUM), + fg_color="#666666", + hover_color="#555555" + ) + no_btn.pack(side="left", padx=5) + + yes_btn = ctk.CTkButton( + button_frame, + text="是", + command=self._on_yes, + width=BUTTON_WIDTH_MEDIUM, + height=BUTTON_HEIGHT_SMALL, + font=ctk.CTkFont(size=FONT_SIZE_MEDIUM) + ) + yes_btn.pack(side="left", padx=5) + yes_btn.focus_set() + else: + # 确定按钮 + ok_btn = ctk.CTkButton( + button_frame, + text="确定", + command=self._on_ok, + width=BUTTON_WIDTH_MEDIUM, + height=BUTTON_HEIGHT_SMALL, + font=ctk.CTkFont(size=FONT_SIZE_MEDIUM) + ) + ok_btn.pack() + ok_btn.focus_set() + + # 绑定 Enter 键 + self.bind("", lambda e: self._on_ok() if self.dialog_type != "question" else self._on_yes()) + self.bind("", lambda e: self._on_no() if self.dialog_type == "question" else self._on_ok()) + + def _get_icon_text(self) -> str: + """获取图标文本""" + icons = { + "info": "i", + "warning": "!", + "error": "X", + "question": "?" + } + return icons.get(self.dialog_type, "i") + + def _get_icon_color(self) -> str: + """获取图标颜色""" + colors = { + "info": "#3584e4", + "warning": "#ff9800", + "error": "#f44336", + "question": "#3584e4" + } + return colors.get(self.dialog_type, "#3584e4") + + def destroy(self): + """销毁对话框前解除事件绑定""" + try: + self.unbind("") + self.unbind("") + except Exception: + pass + return super().destroy() + + def _on_ok(self): + """确定按钮点击""" + self.result = True + self.destroy() + + def _on_yes(self): + """是按钮点击""" + self.result = True + self.destroy() + + def _on_no(self): + """否按钮点击""" + self.result = False + self.destroy() + + +class InputDialog(BaseDialog): + """自定义输入对话框""" + + def __init__(self, parent, title: str, prompt: str, initial_value: str = "", **kwargs): + """ + 初始化输入对话框 + + Args: + parent: 父窗口 + title: 对话框标题 + prompt: 提示文本 + initial_value: 初始值 + """ + super().__init__(parent, title, 400, 180) + + self.result = None + + # 创建界面 + self._create_widgets(prompt, initial_value) + + # 显示对话框 + self.show() + + def _create_widgets(self, prompt: str, initial_value: str): + """创建界面组件""" + # 主容器 + main_frame = ctk.CTkFrame(self, fg_color="transparent") + main_frame.pack(fill="both", expand=True, padx=20, pady=20) + + # 提示文本 + prompt_label = ctk.CTkLabel( + main_frame, + text=prompt, + font=ctk.CTkFont(size=FONT_SIZE_LARGE), + wraplength=360, + justify="left" + ) + prompt_label.pack(pady=(0, 15)) + + # 输入框 + self.entry = ctk.CTkEntry( + main_frame, + height=BUTTON_HEIGHT_SMALL, + font=ctk.CTkFont(size=FONT_SIZE_MEDIUM) + ) + self.entry.pack(fill="x", pady=(0, 20)) + self.entry.insert(0, initial_value) + self.entry.select_range(0, "end") + self.entry.focus_set() + + # 按钮容器 + button_frame = ctk.CTkFrame(main_frame, fg_color="transparent") + button_frame.pack(side="bottom") + + # 取消按钮 + cancel_btn = ctk.CTkButton( + button_frame, + text="取消", + command=self._on_cancel, + width=BUTTON_WIDTH_MEDIUM, + height=BUTTON_HEIGHT_SMALL, + font=ctk.CTkFont(size=FONT_SIZE_MEDIUM), + fg_color="#666666", + hover_color="#555555" + ) + cancel_btn.pack(side="left", padx=5) + + # 确定按钮 + ok_btn = ctk.CTkButton( + button_frame, + text="确定", + command=self._on_ok, + width=BUTTON_WIDTH_MEDIUM, + height=BUTTON_HEIGHT_SMALL, + font=ctk.CTkFont(size=FONT_SIZE_MEDIUM) + ) + ok_btn.pack(side="left", padx=5) + + # 绑定键盘事件 + self.entry.bind("", lambda e: self._on_ok()) + self.bind("", lambda e: self._on_cancel()) + + def destroy(self): + """销毁对话框前解除事件绑定""" + try: + if hasattr(self, 'entry'): + self.entry.unbind("") + self.unbind("") + except Exception: + pass + return super().destroy() + + def _on_ok(self): + """确定按钮点击""" + self.result = self.entry.get().strip() + self.destroy() + + def _on_cancel(self): + """取消按钮点击""" + self.result = None + self.destroy() + + +# 便捷函数 +def show_info(parent, title: str, message: str): + """显示信息对话框""" + MessageDialog(parent, title, message, "info") + + +def show_warning(parent, title: str, message: str): + """显示警告对话框""" + MessageDialog(parent, title, message, "warning") + + +def show_error(parent, title: str, message: str): + """显示错误对话框""" + MessageDialog(parent, title, message, "error") + + +def ask_yes_no(parent, title: str, message: str) -> bool: + """显示是/否对话框""" + dialog = MessageDialog(parent, title, message, "question") + return dialog.result if dialog.result is not None else False + + +def ask_string(parent, title: str, prompt: str, initial_value: str = "") -> Optional[str]: + """显示输入对话框""" + dialog = InputDialog(parent, title, prompt, initial_value) + return dialog.result diff --git a/ui/utilities/icon_manager.py b/ui/utilities/icon_manager.py new file mode 100644 index 0000000..a269680 --- /dev/null +++ b/ui/utilities/icon_manager.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +""" +图标管理器 +负责图标的加载、缓存和管理 +""" +import os +import glob +import customtkinter as ctk +from PIL import Image +from functools import lru_cache +from collections import OrderedDict +from config.constants import APP_ICON_MAPPING + + +class IconManager: + """图标管理器,负责图标的加载、缓存和管理""" + + def __init__(self, icons_dir: str, icon_size: int): + """ + 初始化图标管理器 + + Args: + icons_dir: 图标目录路径 + icon_size: 图标大小 + """ + self.icons_dir = icons_dir + self.icon_size = icon_size + # 使用有界缓存控制内存占用 + self._max_cache_size = 128 + self.cache: OrderedDict[str, ctk.CTkImage] = OrderedDict() + + def get_app_icon(self, app_path: str, config_manager) -> ctk.CTkImage: + """ + 获取应用图标 + + Args: + app_path: 应用路径 + config_manager: 配置管理器 + + Returns: + CTkImage 对象,如果失败则返回 None + """ + # 检查缓存 + if app_path in self.cache: + self.cache.move_to_end(app_path) + return self.cache[app_path] + + try: + # 查找图标路径 + icon_path = self._find_icon_path(app_path, config_manager) + if not icon_path: + return self._get_fallback_icon(app_path) + + # 创建并缓存图标 + ctk_image = self._create_ctk_icon(icon_path) + self.cache[app_path] = ctk_image + # 控制缓存大小 + if len(self.cache) > self._max_cache_size: + self.cache.popitem(last=False) + return ctk_image + + except Exception as e: + print(f"Failed to load icon ({app_path}): {e}") + return self._get_fallback_icon(app_path) + + def _find_icon_path(self, app_path: str, config_manager) -> str: + """ + 查找应用图标路径 + + 查找优先级: + 1. 自定义图标 + 2. 预设图标映射 + 3. 应用名称匹配 + 4. 默认图标 + 5. 任意可用图标 + + Args: + app_path: 应用路径 + config_manager: 配置管理器 + + Returns: + 图标文件路径,如果未找到则返回 None + """ + app_name = os.path.splitext(os.path.basename(app_path))[0] + + # 1. 检查自定义图标 + custom_icon = config_manager.get_app_icon(app_path) + if custom_icon and os.path.exists(custom_icon): + return custom_icon + + # 2. 匹配预设图标 + app_name_lower = app_name.lower() + for key, icon_name in APP_ICON_MAPPING.items(): + if key in app_name_lower: + icon_path = os.path.join(self.icons_dir, f"{icon_name}.png") + if os.path.exists(icon_path): + return icon_path + + # 3. 使用应用名称 + icon_path = os.path.join(self.icons_dir, f"{app_name}.png") + if os.path.exists(icon_path): + return icon_path + + # 4. 使用默认图标 + default_icon = os.path.join(self.icons_dir, "NexusLauncher.ico") + if os.path.exists(default_icon): + return default_icon + + # 5. 使用任意可用图标 + icons = glob.glob(os.path.join(self.icons_dir, "*.png")) + icons.extend(glob.glob(os.path.join(self.icons_dir, "*.ico"))) + return icons[0] if icons else None + + @lru_cache(maxsize=128) + def _load_pil_image(self, icon_path: str) -> Image.Image: + """ + 加载 PIL 图像(带 LRU 缓存) + + 使用 LRU 缓存可以避免重复加载相同的图标文件, + 提升性能并减少磁盘 I/O + + Args: + icon_path: 图标文件路径 + + Returns: + PIL Image 对象 + """ + return Image.open(icon_path) + + def _create_ctk_icon(self, icon_path: str) -> ctk.CTkImage: + """ + 创建 CTk 图标对象 + + Args: + icon_path: 图标文件路径 + + Returns: + CTkImage 对象 + """ + pil_image = self._load_pil_image(icon_path) + icon_display_size = int(self.icon_size * 0.6) + return ctk.CTkImage( + light_image=pil_image, + dark_image=pil_image, + size=(icon_display_size, icon_display_size) + ) + + def _get_fallback_icon(self, app_path: str) -> ctk.CTkImage: + """ + 获取降级图标 + + 当无法加载指定图标时,尝试使用任意可用图标 + + Args: + app_path: 应用路径 + + Returns: + CTkImage 对象,如果失败则返回 None + """ + try: + icons = glob.glob(os.path.join(self.icons_dir, "*.png")) + if icons: + ctk_image = self._create_ctk_icon(icons[0]) + self.cache[app_path] = ctk_image + if len(self.cache) > self._max_cache_size: + self.cache.popitem(last=False) + return ctk_image + except: + pass + return None + + def clear_cache(self): + """清空所有缓存""" + self.cache.clear() + self._load_pil_image.cache_clear() + + def update_icon_size(self, new_size: int): + """ + 更新图标大小 + + 更新图标大小后会清空缓存, + 下次获取图标时会使用新的尺寸重新创建 + + Args: + new_size: 新的图标大小 + """ + self.icon_size = new_size + self.clear_cache() + + def get_cache_info(self) -> dict: + """ + 获取缓存信息 + + Returns: + 包含缓存统计信息的字典 + """ + return { + 'ctk_cache_size': len(self.cache), + 'pil_cache_info': self._load_pil_image.cache_info()._asdict() + } diff --git a/ui/utilities/icon_utils.py b/ui/utilities/icon_utils.py new file mode 100644 index 0000000..f4354fb --- /dev/null +++ b/ui/utilities/icon_utils.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +图标工具模块 +负责图标路径获取和窗口图标设置 +""" +import os +import sys + + +def get_icons_dir() -> str: + """获取 icons 目录的绝对路径 + + Returns: + icons 目录的绝对路径 + """ + # 获取当前文件所在目录(ui/utilities/) + current_dir = os.path.dirname(os.path.abspath(__file__)) + + # 往上两级到项目根目录 + project_root = os.path.dirname(os.path.dirname(current_dir)) + + # 拼接 icons 文件夹 + icons_dir = os.path.join(project_root, "icons") + + # 如果是打包后的应用,使用 sys._MEIPASS + if getattr(sys, 'frozen', False): + # 打包后的应用 + icons_dir = os.path.join(sys._MEIPASS, "icons") + + return icons_dir + + +def get_icon_path(icon_name: str = "NexusLauncher.ico") -> str: + """获取图标文件的绝对路径 + + Args: + icon_name: 图标文件名,默认为 "NexusLauncher.ico" + + Returns: + 图标文件的绝对路径 + """ + icons_dir = get_icons_dir() + icon_path = os.path.join(icons_dir, icon_name) + return icon_path + + +def set_window_icon(window, icon_name: str = "NexusLauncher.ico"): + """为窗口设置图标 + + Args: + window: Tkinter 窗口对象 + icon_name: 图标文件名,默认为 "NexusLauncher.ico" + """ + icon_path = get_icon_path(icon_name) + + if os.path.exists(icon_path): + try: + window.iconbitmap(icon_path) + window.wm_iconbitmap(icon_path) + except Exception as e: + print(f"Failed to set window icon: {e}") + else: + print(f"Icon file not found: {icon_path}") + + +def setup_dialog_icon(dialog, icon_name: str = "NexusLauncher.ico"): + """为对话框设置图标(包含延迟设置以确保生效) + + Args: + dialog: 对话框窗口对象 + icon_name: 图标文件名,默认为 "NexusLauncher.ico" + """ + icon_path = get_icon_path(icon_name) + + if not os.path.exists(icon_path): + return + + def set_icon(): + try: + dialog.iconbitmap(icon_path) + dialog.wm_iconbitmap(icon_path) + except Exception: + pass + + # 立即设置 + set_icon() + + # 延迟设置(确保生效) + dialog.after(50, set_icon) + dialog.after(200, set_icon) diff --git a/ui/utilities/ui_helpers.py b/ui/utilities/ui_helpers.py new file mode 100644 index 0000000..7987f97 --- /dev/null +++ b/ui/utilities/ui_helpers.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +UI辅助工具 +负责标签按钮、下拉框等UI组件的辅助功能 +""" +import customtkinter as ctk +from config.constants import COLOR_TRANSPARENT + + +class UIHelpers: + """UI辅助工具类,提供各种UI组件的辅助功能""" + + @staticmethod + def adjust_tab_button_width(tabview): + """调整标签按钮宽度,让它们平分整个宽度""" + try: + # 获取tabview的宽度 + tabview_width = tabview.winfo_width() + + if tabview_width > 1: + # 获取标签按钮 + segmented_button = tabview._segmented_button + buttons_dict = segmented_button._buttons_dict + + # 计算每个按钮的宽度(平分) + button_count = len(buttons_dict) + if button_count > 0: + button_width = tabview_width // button_count + + # 设置每个按钮的宽度 + for button in buttons_dict.values(): + try: + button.configure(width=button_width) + except: + pass + + print(f"Adjusted tab button width: {button_width}px (total width: {tabview_width}px)") + except Exception as e: + print(f"Failed to adjust tab button width: {e}") + + @staticmethod + def fix_dropdown_width(combo_box): + """修复下拉菜单宽度,使其与下拉框一致""" + try: + # 获取下拉框的实际宽度 + combo_width = combo_box.winfo_width() + + if combo_width > 1: + # 方法1: 直接设置 CTkComboBox 的内部属性 + if hasattr(combo_box, '_dropdown_menu'): + dropdown_menu = combo_box._dropdown_menu + + if dropdown_menu is not None: + # 设置下拉菜单的宽度 + try: + dropdown_menu.configure(width=combo_width) + except: + pass + + # 尝试设置内部 frame 的宽度 + try: + if hasattr(dropdown_menu, 'winfo_children'): + for child in dropdown_menu.winfo_children(): + child.configure(width=combo_width) + except: + pass + + # 方法2: 重写 _open_dropdown_menu 方法 + if not hasattr(combo_box, '_original_open_dropdown'): + combo_box._original_open_dropdown = combo_box._open_dropdown_menu + + def custom_open_dropdown(): + combo_box._original_open_dropdown() + # 延迟设置宽度 + if hasattr(combo_box, 'after'): + combo_box.after(1, lambda: UIHelpers._set_dropdown_width(combo_box, combo_width)) + + combo_box._open_dropdown_menu = custom_open_dropdown + + except Exception as e: + print(f"Failed to set dropdown menu width: {e}") + + @staticmethod + def _set_dropdown_width(combo_box, width): + """设置下拉菜单宽度的辅助方法""" + try: + if hasattr(combo_box, '_dropdown_menu') and combo_box._dropdown_menu is not None: + dropdown = combo_box._dropdown_menu + dropdown.configure(width=width) + + # 遍历所有子组件并设置宽度 + for widget in dropdown.winfo_children(): + try: + widget.configure(width=width) + except: + pass + except: + pass + + @staticmethod + def configure_tab_transparency(project_tab, task_tab): + """配置标签页透明度""" + try: + # 尝试将标签页背景设置为透明,让圆角容器可见 + project_tab.configure(fg_color=COLOR_TRANSPARENT) + task_tab.configure(fg_color=COLOR_TRANSPARENT) + print("[OK] Set tab background to transparent") + except Exception as e: + print(f"Failed to set tab background transparent: {e}") + + @staticmethod + def hide_scrollbar(scrollable_frame): + """隐藏 CTkScrollableFrame 的滚动条(仍保持滚动功能)""" + try: + scrollbar = getattr(scrollable_frame, "_scrollbar", None) + if scrollbar: + bgcolor = scrollable_frame.cget("fg_color") if hasattr(scrollable_frame, "cget") else COLOR_TRANSPARENT + scrollbar.configure(fg_color=COLOR_TRANSPARENT, button_color=bgcolor, button_hover_color=bgcolor) + scrollbar.configure(width=0) + try: + scrollbar.grid_remove() + except Exception: + pass + except Exception: + pass diff --git a/ui/utilities/window_manager.py b/ui/utilities/window_manager.py new file mode 100644 index 0000000..619b339 --- /dev/null +++ b/ui/utilities/window_manager.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +窗口管理器 +负责窗口定位、托盘图标、日志窗口等通用窗口功能 +""" +import os +import sys +import ctypes +import threading +from datetime import datetime +from PIL import Image +import pystray +from pystray import MenuItem as item +import customtkinter as ctk +from config.constants import CONSOLE_WINDOW_SIZE +from .icon_utils import get_icon_path + + +class ConsoleRedirector: + """重定向 stdout 到控制台窗口""" + + def __init__(self, log_callback, original_stdout): + self.log_callback = log_callback + self.original_stdout = original_stdout + self.buffer = "" + + def write(self, message): + """写入消息到控制台窗口和原始 stdout""" + # 同时输出到原始 stdout(命令行)和控制台窗口 + if self.original_stdout: + self.original_stdout.write(message) + self.original_stdout.flush() + + # 输出到控制台窗口 + if message and message.strip(): # 只记录非空消息 + self.log_callback(message.rstrip()) + + def flush(self): + """刷新缓冲区""" + if self.original_stdout: + self.original_stdout.flush() + + +class WindowManager: + """窗口管理器,处理窗口定位、托盘图标、日志窗口等功能""" + + def __init__(self, main_window, config_manager): + """ + 初始化窗口管理器 + + Args: + main_window: 主窗口实例 + config_manager: 配置管理器 + """ + self.main_window = main_window + self.config_manager = config_manager + + # 托盘图标相关 + self.tray_icon = None + self.is_quitting = False + + # 日志窗口相关 + self.console_visible = False + self.console_window = None + self.log_text = None + + # stdout 重定向 + self.original_stdout = sys.stdout + self.console_redirector = None + + # 图标路径 + self.icon_path = get_icon_path() + + def setup_window_appid(self): + """设置Windows AppUserModelID,确保任务栏图标正确显示""" + try: + myappid = 'NexusLauncher.App.1.0' + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) + except: + pass + + def position_window_bottom_right(self, width, height): + """将窗口定位到屏幕右下角(任务栏上方)""" + # 先设置窗口大小 + self.main_window.geometry(f"{width}x{height}") + + # 更新窗口以获取准确的尺寸 + self.main_window.update_idletasks() + + # 获取屏幕尺寸 + screen_width = self.main_window.winfo_screenwidth() + screen_height = self.main_window.winfo_screenheight() + + # 计算右下角位置(留出任务栏空间,确保不重叠) + taskbar_height = 80 # 任务栏高度 + 额外间距,确保不重叠 + x = screen_width - width - 15 # 右边距15px + y = screen_height - height - taskbar_height # 底部留出足够空间 + + # 设置窗口位置 + self.main_window.geometry(f"{width}x{height}+{x}+{y}") + + def set_window_icon(self): + """设置窗口图标""" + if os.path.exists(self.icon_path): + self.main_window.iconbitmap(self.icon_path) + + def setup_tray_icon(self): + """设置系统托盘图标""" + try: + # 加载图标 + if os.path.exists(self.icon_path): + icon_image = Image.open(self.icon_path) + else: + # 如果图标不存在,创建一个简单的默认图标 + icon_image = Image.new('RGB', (64, 64), color='blue') + + # 创建托盘菜单 + console_text = '隐藏日志' if self.console_visible else '显示日志' + menu = pystray.Menu( + item('显示主窗口', self._show_window, default=True), + item('设置', self._show_settings), + item(console_text, self._toggle_console), + pystray.Menu.SEPARATOR, + item('退出', self._quit_app) + ) + + # 创建托盘图标 + self.tray_icon = pystray.Icon( + "NexusLauncher", + icon_image, + "NexusLauncher", + menu + ) + + # 在单独的线程中运行托盘图标 + tray_thread = threading.Thread(target=self.tray_icon.run, daemon=True) + tray_thread.start() + + except Exception as e: + print(f"Failed to create tray icon: {e}") + + def create_log_window(self): + """创建自定义日志窗口""" + self.console_window = ctk.CTkToplevel(self.main_window) + self.console_window.title("NexusLauncher - 控制台") + self.console_window.geometry(CONSOLE_WINDOW_SIZE) + + # 设置图标 - 使用持续监控方式 + if os.path.exists(self.icon_path): + self.console_window.iconbitmap(self.icon_path) + # 持续设置图标,防止被 CustomTkinter 覆盖 + self._keep_console_icon_alive() + + # 创建文本框显示日志 + self.log_text = ctk.CTkTextbox( + self.console_window, + wrap="word", + font=ctk.CTkFont(family="Consolas", size=12) + ) + self.log_text.pack(fill="both", expand=True, padx=10, pady=10) + + # 添加欢迎信息 + self.log_text.insert("1.0", "NexusLauncher 控制台\n") + self.log_text.insert("end", "=" * 50 + "\n") + self.log_text.insert("end", "实时显示应用调试信息\n") + self.log_text.insert("end", "关闭此窗口不会退出应用\n") + self.log_text.insert("end", "=" * 50 + "\n\n") + + # 绑定关闭事件 - 只隐藏不退出 + self.console_window.protocol("WM_DELETE_WINDOW", self._on_log_window_close) + + # 重定向 stdout 到控制台窗口 + self._redirect_stdout() + + def show_console(self): + """显示日志窗口""" + if not self.console_window or not self.console_window.winfo_exists(): + self.create_log_window() + else: + self.console_window.deiconify() + self.console_window.lift() + self.console_visible = True + # 更新托盘菜单 + if self.tray_icon: + self._update_tray_menu() + self.log_with_timestamp("[VIEW] 日志窗口已显示") + + def hide_console(self): + """隐藏日志窗口""" + if self.console_window and self.console_window.winfo_exists(): + self.console_window.withdraw() + self.console_visible = False + # 更新托盘菜单 + if self.tray_icon: + self._update_tray_menu() + print("[VIEW] Console window hidden") + + def log(self, message: str): + """记录日志到控制台窗口(不带时间戳,用于 print 重定向)""" + if self.log_text: + try: + self.log_text.insert("end", f"{message}\n") + self.log_text.see("end") # 自动滚动到最新日志 + except: + pass + + def log_with_timestamp(self, message: str): + """记录带时间戳的日志到控制台窗口(用于重要事件)""" + if self.log_text: + try: + timestamp = datetime.now().strftime("%H:%M:%S") + self.log_text.insert("end", f"[{timestamp}] {message}\n") + self.log_text.see("end") # 自动滚动到最新日志 + except: + pass + + def _redirect_stdout(self): + """重定向 stdout 到控制台窗口""" + if not self.console_redirector: + self.console_redirector = ConsoleRedirector(self.log, self.original_stdout) + sys.stdout = self.console_redirector + + def _restore_stdout(self): + """恢复原始 stdout""" + if self.console_redirector: + sys.stdout = self.original_stdout + self.console_redirector = None + + def hide_window(self): + """隐藏窗口到托盘""" + self.main_window.withdraw() + + def show_window(self): + """显示主窗口""" + self.main_window.deiconify() + self.main_window.lift() + self.main_window.focus_force() + + def quit_app(self): + """退出应用程序""" + self.is_quitting = True + + # 恢复原始 stdout + self._restore_stdout() + + # 关闭自定义日志窗口 + if self.console_window and self.console_window.winfo_exists(): + self.console_window.destroy() + + # 停止托盘图标 + if self.tray_icon: + self.tray_icon.stop() + + # 保存窗口大小 + try: + width = self.main_window.winfo_width() + height = self.main_window.winfo_height() + self.config_manager.save_window_size(width, height) + except: + pass + + # 关闭应用 + self.main_window.after(0, self.main_window.quit) + + def _keep_console_icon_alive(self): + """持续保持控制台图标不被覆盖""" + try: + if self.console_window and self.console_window.winfo_exists() and os.path.exists(self.icon_path): + self.console_window.iconbitmap(self.icon_path) + # 每 50ms 检查一次,持续 500ms + if not hasattr(self, '_console_icon_check_count'): + self._console_icon_check_count = 0 + + if self._console_icon_check_count < 10: + self._console_icon_check_count += 1 + self.main_window.after(50, self._keep_console_icon_alive) + except: + pass + + def _on_log_window_close(self): + """日志窗口关闭事件""" + self.hide_console() + + def _toggle_console(self, icon=None, item=None): + """切换控制台窗口显示状态""" + if self.console_visible: + self.main_window.after(0, self.hide_console) + else: + self.main_window.after(0, self.show_console) + + def _update_tray_menu(self): + """更新托盘菜单""" + try: + console_text = '隐藏日志' if self.console_visible else '显示日志' + menu = pystray.Menu( + item('显示主窗口', self._show_window, default=True), + item('设置', self._show_settings), + item(console_text, self._toggle_console), + pystray.Menu.SEPARATOR, + item('退出', self._quit_app) + ) + self.tray_icon.menu = menu + except Exception as e: + print(f"Failed to update tray menu: {e}") + + def _show_window(self, icon=None, item=None): + """显示主窗口""" + self.main_window.after(0, self.show_window) + + def _show_settings(self, icon=None, item=None): + """显示设置窗口""" + self.main_window.after(0, self._do_show_settings) + + def _do_show_settings(self): + """在主线程中显示设置窗口""" + # 如果窗口隐藏,先显示主窗口 + if not self.main_window.winfo_viewable(): + self.main_window.deiconify() + + # 调用主窗口的设置方法 + if hasattr(self.main_window, '_open_settings'): + self.main_window._open_settings() + + def _quit_app(self, icon=None, item=None): + """退出应用程序""" + self.quit_app() diff --git a/ui/utilities/window_utils.py b/ui/utilities/window_utils.py new file mode 100644 index 0000000..d26648a --- /dev/null +++ b/ui/utilities/window_utils.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +窗口工具模块 +负责窗口样式设置和对话框配置 +""" +import ctypes +from .icon_utils import setup_dialog_icon + + +def set_dark_title_bar(window): + """设置窗口为深色标题栏(Windows 10/11) + + Args: + window: 窗口对象 + """ + try: + hwnd = ctypes.windll.user32.GetParent(window.winfo_id()) + DWMWA_USE_IMMERSIVE_DARK_MODE = 20 + value = ctypes.c_int(2) + ctypes.windll.dwmapi.DwmSetWindowAttribute( + hwnd, + DWMWA_USE_IMMERSIVE_DARK_MODE, + ctypes.byref(value), + ctypes.sizeof(value) + ) + except Exception as e: + print(f"Failed to set dark title bar: {e}") + + +def setup_dialog_window(dialog, title: str, width: int, height: int, parent=None, center: bool = True): + """统一配置对话框窗口 + + Args: + dialog: 对话框对象 + title: 窗口标题 + width: 窗口宽度 + height: 窗口高度 + parent: 父窗口(可选) + center: 是否居中显示,默认 True + + Returns: + 配置好的对话框对象 + """ + dialog.title(title) + dialog.geometry(f"{width}x{height}") + dialog.configure(fg_color="#2b2b2b") + + # 设置图标 + setup_dialog_icon(dialog) + + # 设置为模态 + if parent: + dialog.transient(parent) + dialog.grab_set() + + # 设置深色标题栏 + dialog.after(10, lambda: set_dark_title_bar(dialog)) + + # 居中显示 + if center: + dialog.update_idletasks() + x = (dialog.winfo_screenwidth() // 2) - (width // 2) + y = (dialog.winfo_screenheight() // 2) - (height // 2) + dialog.geometry(f"{width}x{height}+{x}+{y}") + + return dialog