""" ui/pet_window.py ================ EzVibe 桌宠渲染窗口 —— PySide6/PyQt6 实现(延迟类定义,headless 测试友好)。 功能 ---- - 无边框、置顶、透明背景的桌面宠物窗口 - 情绪状态驱动动画(idle / happy / focused / annoyed / sleepy) - 拖拽移动、右键菜单、最小化到托盘 - QTimer 定期触发 brain 轮询(主动行为推送) - 提醒通知弹窗(健康喝水/伸展提醒) - 图像资源:assets/pet/{emotion}/ 系列 PNG,支持透明背景 - 无图像时的 emoji 回退显示 - Dummy 模式:headless 测试不依赖 Qt 资源结构 -------- assets/ └── pet/ ├── idle/ (可选,emoji fallback) ├── happy/ ├── focused/ ├── annoyed/ └── sleepy/ 用法 ---- from ui.pet_window import create_pet_window window = create_pet_window( emotion_engine=emotion, brain=brain, scheduler=scheduler, force_dummy=True, # 无 Qt 时强制 DummyPetWindow ) window.show() """ from __future__ import annotations import os import sys import threading from pathlib import Path from typing import Literal # --------------------------------------------------------------------------- # Qt 框架检测 # --------------------------------------------------------------------------- _Qt = None # "PySide6" or "PyQt6" def _load_qt() -> str | None: """尝试导入 PySide6 或 PyQt6,返回库名或 None。""" global _Qt if _Qt is not None: return _Qt for lib in ("PySide6", "PyQt6"): try: # 直接 import 子模块,__import__(..., fromlist=['']) 触发完整路径导入 __import__(f"{lib}.QtCore", fromlist=[""]) __import__(f"{lib}.QtWidgets", fromlist=[""]) _Qt = lib return lib except (ImportError, AttributeError): continue return None # --------------------------------------------------------------------------- # DummyPetWindow — headless 测试替代 # --------------------------------------------------------------------------- class DummyPetWindow: """ 桌宠窗口的 Dummy 替代(不依赖 Qt)。 用于 headless 测试、CI 环境和无图形界面的服务器。 """ EMOTIONS = ("idle", "happy", "focused", "annoyed", "sleepy") EMOTION_EMOJIS = { "idle": "🐱", "happy": "😸", "focused": "😼", "annoyed": "😾", "sleepy": "😺", } def __init__( self, emotion_engine=None, brain=None, scheduler=None, use_assets: bool = False, ) -> None: self._emotion = emotion_engine self._brain = brain self._scheduler = scheduler self._visible = True self._current_emotion = "idle" self._position = (100, 100) self._reminders: list[str] = [] self._running = False def __repr__(self) -> str: return ( f"" ) # ── 模拟 Qt API ────────────────────────────────────────────────────── def show(self) -> None: self._visible = True self._running = True def hide(self) -> None: self._visible = False def close(self) -> None: self._visible = False self._running = False def setWindowTitle(self, title: str) -> None: pass def update_emotion(self, emotion: str) -> None: if emotion in self.EMOTIONS: self._current_emotion = emotion def play_animation(self, anim_name: str) -> None: pass def show_reminder(self, message: str, priority: int = 0) -> None: self._reminders.append(message) def get_position(self) -> tuple[int, int]: return self._position def set_position(self, x: int, y: int) -> None: self._position = (x, y) def start_timer(self, interval_ms: int, callback) -> None: """模拟 QTimer。""" callback() def geometry(self): """返回模拟的 QRect。""" class _DummyRect: def __init__(self, x, y, w, h): self._x, self._y, self._w, self._h = x, y, w, h def x(self): return self._x def y(self): return self._y def width(self): return self._w def height(self): return self._h def right(self): return self._x + self._w def top(self): return self._y def __repr__(self): return f"QRect({self._x}, {self._y}, {self._w}, {self._h})" return _DummyRect(self._position[0], self._position[1], 200, 200) def is_visible(self) -> bool: return self._visible # Qt API 别名(PetWindow 用 isVisible(),DummyPetWindow 保持兼容) isVisible = is_visible def get_current_emotion(self) -> str: return self._current_emotion # --------------------------------------------------------------------------- # PetWindow — PySide6/PyQt6 实现(动态构造) # --------------------------------------------------------------------------- def _make_pet_window_class(): """ 动态构造 PetWindow 类(仅当 Qt 可用时调用)。 使用 type() 避免模块导入时引用未定义的 QtWidgets。 """ if _load_qt() is None: raise ImportError("需要 PySide6 或 PyQt6: pip install PySide6") if _Qt == "PySide6": from PySide6 import QtCore, QtWidgets, QtGui else: from PyQt6 import QtCore, QtWidgets, QtGui # ── 实例方法定义 ────────────────────────────────────────────────────── def __init__(self, emotion_engine=None, brain=None, scheduler=None, assets_dir=None): self._QtCore = QtCore self._QtWidgets = QtWidgets self._QtGui = QtGui # 注意:用 type(self).__bases__[0] 代替 super(PetWindow, self) # 因为 PetWindow 闭包在 _make_pet_window_class() 被调用前 # 捕获的是外层 PetWindow = None,直接 super(None) 会报 TypeError type(self).__bases__[0].__init__(self) self._emotion = emotion_engine self._brain = brain self._scheduler = scheduler if assets_dir: self._assets_dir = Path(assets_dir) else: self._assets_dir = Path(__file__).parent.parent / "assets" / "pet" self._current_emotion = "idle" self._current_frame = 0 self._frames: list = [] self._is_dragging = False self._drag_offset = QtCore.QPoint() self._running = True self._brain_timer: QtCore.QTimer | None = None self._init_ui() self._init_tray() self._init_brain_timer() self._start_animation("idle") # ── UI 初始化 ──────────────────────────────────────────────────────── def _init_ui(self): flags = ( self._QtCore.Qt.WindowType.FramelessWindowHint | self._QtCore.Qt.WindowType.WindowStaysOnTopHint | self._QtCore.Qt.WindowType.Tool ) self.setWindowFlags(flags) central = self._QtWidgets.QWidget() self.setCentralWidget(central) # 可见背景色,让透明 PNG 能被看见 central.setStyleSheet("background: rgba(200, 215, 240, 200);") self._label = self._QtWidgets.QLabel(central) self._label.setAlignment(self._QtCore.Qt.AlignmentFlag.AlignCenter) self._label.setStyleSheet("background: transparent;") self._label.setFixedSize(180, 180) self._label.move(10, 10) # 居中于 200x200 窗口 self.setContextMenuPolicy( self._QtCore.Qt.ContextMenuPolicy.CustomContextMenu ) self.customContextMenuRequested.connect(self._show_context_menu) self.resize(200, 200) screen = self._QtWidgets.QApplication.primaryScreen() if screen: geo = screen.geometry() self.move(geo.width() // 2 - 100, geo.height() // 2 - 100) def _init_tray(self): try: self._tray = self._QtWidgets.QSystemTrayIcon(self) self._tray.setToolTip("EzVibe 桌宠") self._tray.activated.connect(self._on_tray_activated) except Exception: self._tray = None def _init_brain_timer(self): self._brain_timer = self._QtCore.QTimer(self) self._brain_timer.timeout.connect(self._on_brain_tick) self._brain_timer.start(30_000) # ── 资源加载 ───────────────────────────────────────────────────────── def _load_pet_pixmap(self, emotion, frame=0): base = self._assets_dir / emotion for candidate in [ base / f"pet_{emotion}_{frame}.png", base / f"{emotion}_{frame}.png", base / f"{emotion}.png", ]: if candidate.exists(): pm = self._QtGui.QPixmap(str(candidate)) if not pm.isNull(): return pm return None def _build_emoji_pixmap(self, emoji): font_size = 80 tmp = self._QtWidgets.QLabel() font = tmp.font() font.setPointSize(font_size) tmp.setFont(font) tmp.setText(emoji) tmp.resize(100, 100) tmp.setStyleSheet("background: transparent;") tmp.setAlignment(self._QtCore.Qt.AlignmentFlag.AlignCenter) return tmp.grab() # ── 动画 ───────────────────────────────────────────────────────────── def _load_frames(self, emotion): self._frames = [] for i in range(16): pm = self._load_pet_pixmap(emotion, frame=i) if pm is None: break self._frames.append(pm) if not self._frames: emoji = type(self).EMOTION_EMOJIS.get(emotion, "🐱") for _ in range(2): self._frames.append(self._build_emoji_pixmap(emoji)) def _start_animation(self, emotion): self._load_frames(emotion) self._current_emotion = emotion self._current_frame = 0 # 立即显示第一帧(无需等待 timer) self._display_frame(0) if len(self._frames) <= 1: return fps = type(self).ANIM_FPS.get(emotion, 500) self._animation_timer = self._QtCore.QTimer(self) self._animation_timer.timeout.connect(self._advance_frame) self._animation_timer.start(fps) def _advance_frame(self): if not self._frames: return self._current_frame = (self._current_frame + 1) % len(self._frames) self._display_frame(self._current_frame) def _display_frame(self, idx): if idx < len(self._frames): pm = self._frames[idx] if not pm.isNull(): scaled = pm.scaled( 180, 180, self._QtCore.Qt.AspectRatioMode.KeepAspectRatio, self._QtCore.Qt.TransformationMode.SmoothTransformation, ) self._label.setPixmap(scaled) # ── 公开 API ───────────────────────────────────────────────────────── def update_emotion(self, emotion): if emotion not in type(self).EMOTIONS: emotion = "idle" if emotion != self._current_emotion: self._start_animation(emotion) def play_animation(self, anim_name): if not self._frames: return original = self._current_frame bounce_frames = list(range(len(self._frames))) * 2 idx = 0 def _bounce(): nonlocal idx self._current_frame = bounce_frames[idx % len(bounce_frames)] self._display_frame(self._current_frame) idx += 1 if idx >= len(bounce_frames) * 2: timer.stop() self._current_frame = original self._display_frame(original) timer = self._QtCore.QTimer(self) timer.timeout.connect(_bounce) timer.start(100) def show_reminder(self, message, priority=0): self._show_notification(message, priority) def set_opacity(self, opacity): self.setWindowOpacity(max(0.0, min(1.0, opacity))) # ── 内部事件 ───────────────────────────────────────────────────────── def _show_notification(self, message, priority): popup = self._QtWidgets.QLabel(self) popup.setWindowFlags( self._QtCore.Qt.WindowType.ToolTip | self._QtCore.Qt.WindowType.FramelessWindowHint | self._QtCore.Qt.WindowType.WindowStaysOnTopHint ) popup.setAttribute( self._QtCore.Qt.WidgetAttribute.WA_TranslucentBackground ) popup.setStyleSheet( "background: rgba(255,255,255,240);" "border-radius: 10px; padding: 8px 14px;" "color: #333; font-size: 14px; font-family: sans-serif;" ) popup.setText(f"💡 {message}") popup.adjustSize() geo = self.geometry() screen = self._QtWidgets.QApplication.primaryScreen() if screen: popup_x = min( geo.right() + 5, screen.geometry().right() - popup.width() - 10 ) else: popup_x = geo.right() + 5 popup_y = geo.top() popup.move(popup_x, popup_y) popup.show() t = self._QtCore.QTimer(popup) t.timeout.connect(popup.deleteLater) t.setSingleShot(True) t.start(5000) def _show_context_menu(self, pos): menu = self._QtWidgets.QMenu() menu.setStyleSheet( "QMenu { background: rgba(255,255,255,240); border-radius: 8px; }" ) menu.addAction("😸 开心", lambda: self.update_emotion("happy")) menu.addAction("😼 专注", lambda: self.update_emotion("focused")) menu.addAction("😾 烦躁", lambda: self.update_emotion("annoyed")) menu.addAction("😺 困了", lambda: self.update_emotion("sleepy")) menu.addAction("🐱 待机", lambda: self.update_emotion("idle")) menu.addSeparator() menu.addAction( "📋 测试提醒", lambda: self.show_reminder("该喝水了!", priority=0) ) menu.addSeparator() menu.addAction("❌ 退出", self.close) menu.exec(self.mapToGlobal(pos)) def _on_tray_activated(self, reason): tray = getattr(self, "_tray", None) if tray and reason == ( self._QtWidgets.QSystemTrayIcon.ActivationReason.DoubleClick ): self.show() self.activateWindow() def _on_brain_tick(self): if not self._brain or not self._running: return def _do_tick(): try: import asyncio result = asyncio.run(self._brain.decide_action()) if result and result.get("action"): action = result["action"] self.show_reminder( action.get("message", ""), priority=action.get("priority", 1) ) except Exception: pass t = threading.Thread(target=_do_tick, daemon=True) t.start() # ── 鼠标拖拽 ───────────────────────────────────────────────────────── def mousePressEvent(self, event): if event.button() == self._QtCore.Qt.MouseButton.LeftButton: self._is_dragging = True self._drag_offset = ( event.globalPosition().toPoint() - self.frameGeometry().topLeft() ) event.accept() def mouseMoveEvent(self, event): if (event.buttons() == self._QtCore.Qt.MouseButton.LeftButton and self._is_dragging): self.move(event.globalPosition().toPoint() - self._drag_offset) event.accept() def mouseReleaseEvent(self, event): if event.button() == self._QtCore.Qt.MouseButton.LeftButton: self._is_dragging = False # ── 生命周期 ───────────────────────────────────────────────────────── def show(self): self._running = True type(self).__bases__[0].show(self) def hide(self): self._running = False type(self).__bases__[0].hide(self) def close(self): self._running = False if self._brain_timer: self._brain_timer.stop() if hasattr(self, "_animation_timer"): self._animation_timer.stop() type(self).__bases__[0].close(self) # ── 类属性(必须与 DummyPetWindow 对齐) ────────────────────────────── EMOTIONS = ("idle", "happy", "focused", "annoyed", "sleepy") EMOTION_EMOJIS = { "idle": "🐱", "happy": "😸", "focused": "😼", "annoyed": "😾", "sleepy": "😺", } ANIM_FPS = { "idle": 2000, "happy": 300, "focused": 800, "annoyed": 400, "sleepy": 3000, } # 用 type() 动态构造 PetWindow(避免模块导入时引用 QtWidgets) PetWindowClass = type( "PetWindow", (QtWidgets.QMainWindow,), { "__init__": __init__, "_init_ui": _init_ui, "_init_tray": _init_tray, "_init_brain_timer": _init_brain_timer, "_load_pet_pixmap": _load_pet_pixmap, "_build_emoji_pixmap": _build_emoji_pixmap, "_load_frames": _load_frames, "_start_animation": _start_animation, "_advance_frame": _advance_frame, "_display_frame": _display_frame, "update_emotion": update_emotion, "play_animation": play_animation, "show_reminder": show_reminder, "set_opacity": set_opacity, "_show_notification": _show_notification, "_show_context_menu": _show_context_menu, "_on_tray_activated": _on_tray_activated, "_on_brain_tick": _on_brain_tick, "mousePressEvent": mousePressEvent, "mouseMoveEvent": mouseMoveEvent, "mouseReleaseEvent": mouseReleaseEvent, "show": show, "hide": hide, "close": close, "EMOTIONS": EMOTIONS, "EMOTION_EMOJIS": EMOTION_EMOJIS, "ANIM_FPS": ANIM_FPS, }, ) return PetWindowClass # 模块级占位符(PetWindow 在 Qt 可用时通过 create_pet_window 动态设置) PetWindow = None # --------------------------------------------------------------------------- # 工厂函数 # --------------------------------------------------------------------------- def create_pet_window( emotion_engine=None, brain=None, scheduler=None, assets_dir: str | Path | None = None, force_dummy: bool = False, ): """ 创建桌宠窗口实例。 参数 ---- force_dummy : True=强制使用 DummyPetWindow(无 Qt 依赖) """ global PetWindow if force_dummy or _load_qt() is None: return DummyPetWindow( emotion_engine=emotion_engine, brain=brain, scheduler=scheduler, ) # 动态构造 PetWindow 类(仅在首次调用时) if PetWindow is None: PetWindow = _make_pet_window_class() return PetWindow( emotion_engine=emotion_engine, brain=brain, scheduler=scheduler, assets_dir=assets_dir, )