Files
EzVibe/ui/pet_window.py

600 lines
20 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.
"""
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,
)