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

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