This commit is contained in:
2025-11-23 20:41:50 +08:00
commit f7d5b7be07
65 changed files with 14986 additions and 0 deletions

45
.gitignore vendored Normal file
View File

@@ -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/

37
CleanCache.bat Normal file
View File

@@ -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

28
Run.bat Normal file
View File

@@ -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

43
RunDebug.bat Normal file
View File

@@ -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

3
RunHidden.vbs Normal file
View File

@@ -0,0 +1,3 @@
Set WshShell = CreateObject("WScript.Shell")
WshShell.Run "pythonw main.py", 0, False
Set WshShell = Nothing

61
build.bat Normal file
View File

@@ -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

13
config/__init__.py Normal file
View File

@@ -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']

623
config/config_manager.py Normal file
View File

@@ -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

383
config/constants.py Normal file
View File

@@ -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

312
config/icon_config.py Normal file
View File

@@ -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

298
docs/ARTIST_GUIDE.md Normal file
View File

@@ -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 - 让项目管理更简单* 🚀

577
docs/CUSTOM_PLUGIN_GUIDE.md Normal file
View File

@@ -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 官方文档
---
**祝你开发愉快!** 🎉

531
docs/README.md Normal file
View File

@@ -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 等)
- 临时工具架加载(不保存到用户配置)
- 智能清理机制(退出时自动清理)
- 支持多版本 Maya2023、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 等)
- 临时工具架加载(不保存到用户配置文件)
- 智能清理机制(启动时和退出时自动清理)
- 支持多版本 Maya2023、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)

BIN
icons/3DsMax.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

BIN
icons/Billfish.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

BIN
icons/Blender.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
icons/CharacterCreater.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
icons/Eagle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
icons/EpicGames.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
icons/EpicGamesLauncher.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
icons/Everything.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

BIN
icons/Houdini.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

BIN
icons/MarmosetToolBag.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

BIN
icons/MarvelousDesigner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
icons/Maya.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

BIN
icons/NexusLauncher.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

BIN
icons/P4V.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
icons/Perforce.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
icons/Photoshop.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

BIN
icons/PureRef.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
icons/RizomUV.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
icons/SubstanceDesigner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

BIN
icons/SubstancePainter.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

BIN
icons/UEFN.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
icons/UnrealEngine.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

BIN
icons/UnrealGameSync.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
icons/Wrap4D.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
icons/Zbrush.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

384
main.py Normal file
View File

@@ -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("<Button-1>", self._on_combo_click, add="+")
self.bind_all("<Control-MouseWheel>", self._on_zoom, add="+")
self.bind("<Configure>", 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()

2155
plugins/Qt.py Normal file

File diff suppressed because it is too large Load Diff

92
plugins/__init__.py Normal file
View File

@@ -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']

245
plugins/maya.py Normal file
View File

@@ -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()

View File

@@ -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']

View File

@@ -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()

View File

@@ -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

View File

@@ -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()

5
requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
customtkinter>=5.2.0
Pillow>=10.0.0
pystray>=0.19.0
pyinstaller>=6.0.0
pywin32>=306

51
ui/__init__.py Normal file
View File

@@ -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'
]

10
ui/project/__init__.py Normal file
View File

@@ -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"]

533
ui/project/project_panel.py Normal file
View File

@@ -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))

2121
ui/settings_window.py Normal file

File diff suppressed because it is too large Load Diff

138
ui/splash_screen.py Normal file
View File

@@ -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

12
ui/task/__init__.py Normal file
View File

@@ -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"]

138
ui/task/node.py Normal file
View File

@@ -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"<Node id='{self.id}' name='{self.name}'>"

1695
ui/task/subfolder_editor.py Normal file

File diff suppressed because it is too large Load Diff

2094
ui/task/task_panel.py Normal file

File diff suppressed because it is too large Load Diff

82
ui/utilities/__init__.py Normal file
View File

@@ -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'
]

View File

@@ -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()

View File

@@ -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}'

View File

@@ -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("<Return>", lambda e: self._on_ok() if self.dialog_type != "question" else self._on_yes())
self.bind("<Escape>", 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("<Return>")
self.unbind("<Escape>")
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("<Return>", lambda e: self._on_ok())
self.bind("<Escape>", lambda e: self._on_cancel())
def destroy(self):
"""销毁对话框前解除事件绑定"""
try:
if hasattr(self, 'entry'):
self.entry.unbind("<Return>")
self.unbind("<Escape>")
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

View File

@@ -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()
}

View File

@@ -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)

126
ui/utilities/ui_helpers.py Normal file
View File

@@ -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

View File

@@ -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()

View File

@@ -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