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

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