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

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