#!/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))