600 lines
20 KiB
Python
600 lines
20 KiB
Python
"""
|
||
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"<DummyPetWindow emotion={self._current_emotion} "
|
||
f"visible={self._visible} pos={self._position}>"
|
||
)
|
||
|
||
# ── 模拟 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"<b>💡</b> {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,
|
||
)
|