534 lines
21 KiB
Python
534 lines
21 KiB
Python
#!/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))
|