Update
This commit is contained in:
10
ui/project/__init__.py
Normal file
10
ui/project/__init__.py
Normal 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
533
ui/project/project_panel.py
Normal 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))
|
||||
Reference in New Issue
Block a user