Files
NexusLauncher/ui/utilities/window_manager.py
2025-11-23 20:41:50 +08:00

328 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()