fix: add Linux desktop pet implementation

This commit is contained in:
Stark-X
2026-04-25 10:58:38 +08:00
parent 114dfdb211
commit 09cf6306af

View File

@@ -609,201 +609,455 @@ if sys.platform == 'darwin':
self.frame_idx = 0 self.frame_idx = 0
# ============================================================================ # ============================================================================
# Windows Implementation - tkinter with transparentcolor # Windows/Linux Implementations
# ============================================================================ # ============================================================================
else: else:
import tkinter as tk if sys.platform.startswith('win'):
from PIL import ImageTk import tkinter as tk
from PIL import ImageTk
class WinPet(PetBase): class WinPet(PetBase):
def __init__(self, skin_name=None): def __init__(self, skin_name=None):
self.root = tk.Tk() self.root = tk.Tk()
self.root.wm_attributes('-topmost', True) self.root.wm_attributes('-topmost', True)
self.is_windows = sys.platform.startswith('win')
self.platform_name = 'Windows' if self.is_windows else 'Linux'
self.pet_bg_color = '#F0F0F0' if self.is_windows else 'black'
self.toast_bg_color = '#00ff01' if self.is_windows else 'black'
# Load skin # Load skin
self.load_skin(skin_name) self.load_skin(skin_name)
# Setup window # Setup window
screen_width = self.root.winfo_screenwidth() screen_width = self.root.winfo_screenwidth()
screen_height = self.root.winfo_screenheight() screen_height = self.root.winfo_screenheight()
x_pos = screen_width - 200 x_pos = screen_width - 200
y_pos = screen_height - 300 y_pos = screen_height - 300
self.root.geometry(f'{self.display_width}x{self.display_height}+{x_pos}+{y_pos}') self.root.geometry(f'{self.display_width}x{self.display_height}+{x_pos}+{y_pos}')
self.root.overrideredirect(True) self.root.overrideredirect(True)
self.root.wm_attributes('-topmost', True) self.root.wm_attributes('-topmost', True)
# Transparent background # Transparent background
self.root.wm_attributes('-transparentcolor', '#F0F0F0') if self.is_windows:
self.root.config(bg='#F0F0F0') self.root.wm_attributes('-transparentcolor', self.pet_bg_color)
self.root.config(bg=self.pet_bg_color)
# Create label # Create label
self.label = tk.Label(self.root, bg='#F0F0F0', bd=0) self.label = tk.Label(self.root, bg=self.pet_bg_color, bd=0)
self.label.pack() self.label.pack()
# Bind events # Bind events
self.label.bind('<Button-1>', lambda e: setattr(self, '_d', (e.x, e.y))) self.label.bind('<Button-1>', lambda e: setattr(self, '_d', (e.x, e.y)))
self.label.bind('<B1-Motion>', self._drag) self.label.bind('<B1-Motion>', self._drag)
self.label.bind('<Double-1>', lambda e: (self.root.destroy(), os._exit(0))) self.label.bind('<Double-1>', lambda e: (self.root.destroy(), os._exit(0)))
self.label.bind('<Button-3>', self._on_right_click) self.label.bind('<Button-3>', self._on_right_click)
# Animation state # Animation state
self.current_state = 'idle' self.current_state = 'idle'
self.frame_idx = 0
# Toast state
self.toast_window = None
self.toast_photo = None
# Start animation
self._animate()
self._start_server()
print(f"✓ Windows Pet started at ({x_pos}, {y_pos})")
print(f" Animations: {', '.join(self.animations.keys())}")
self.root.mainloop()
def load_skin(self, skin_name=None):
"""Load skin configuration and animations"""
available_skins = SkinLoader.list_skins()
if not available_skins:
raise FileNotFoundError(f"No skins found in {SKINS_DIR}")
if skin_name is None or skin_name not in available_skins:
skin_name = available_skins[0]
skin_path = os.path.join(SKINS_DIR, skin_name)
self.skin_config = SkinLoader.load_skin(skin_path)
# Get display size
display_size = self.skin_config.get('size', {})
self.display_width = display_size.get('width', 128)
self.display_height = display_size.get('height', 128)
# Load animations
self.animations = {}
for anim_name, anim_config in self.skin_config['animations'].items():
pil_frames = AnimationLoader.load_sprite_frames(skin_path, anim_config)
# Scale and convert frames
tk_frames = []
for frame in pil_frames:
if frame.mode != 'RGBA':
frame = frame.convert('RGBA')
scaled = frame.resize((self.display_width, self.display_height), Image.NEAREST)
tk_frames.append(ImageTk.PhotoImage(scaled))
self.animations[anim_name] = {
'frames': tk_frames,
'fps': anim_config.get('sprite', {}).get('fps', 6)
}
def set_state(self, state):
"""Change animation state"""
if state in self.animations and state != self.current_state:
self.current_state = state
self.frame_idx = 0 self.frame_idx = 0
print(f"→ State: {state}")
def _drag(self, e): # Toast state
x = self.root.winfo_x() + e.x - self._d[0] self.toast_window = None
y = self.root.winfo_y() + e.y - self._d[1] self.toast_photo = None
self.root.geometry(f'+{x}+{y}')
def _animate(self): # Start animation
"""Animate current state""" self._animate()
if self.current_state not in self.animations: self._start_server()
self.root.after(100, self._animate)
return
anim = self.animations[self.current_state] print(f"{self.platform_name} Pet started at ({x_pos}, {y_pos})")
frames = anim['frames'] print(f" Animations: {', '.join(self.animations.keys())}")
if frames: self.root.mainloop()
self.label.config(image=frames[self.frame_idx])
def load_skin(self, skin_name=None):
"""Load skin configuration and animations"""
available_skins = SkinLoader.list_skins()
if not available_skins:
raise FileNotFoundError(f"No skins found in {SKINS_DIR}")
if skin_name is None or skin_name not in available_skins:
skin_name = available_skins[0]
skin_path = os.path.join(SKINS_DIR, skin_name)
self.skin_config = SkinLoader.load_skin(skin_path)
# Get display size
display_size = self.skin_config.get('size', {})
self.display_width = display_size.get('width', 128)
self.display_height = display_size.get('height', 128)
# Load animations
self.animations = {}
for anim_name, anim_config in self.skin_config['animations'].items():
pil_frames = AnimationLoader.load_sprite_frames(skin_path, anim_config)
# Scale and convert frames
tk_frames = []
for frame in pil_frames:
if frame.mode != 'RGBA':
frame = frame.convert('RGBA')
scaled = frame.resize((self.display_width, self.display_height), Image.NEAREST)
tk_frames.append(ImageTk.PhotoImage(scaled))
self.animations[anim_name] = {
'frames': tk_frames,
'fps': anim_config.get('sprite', {}).get('fps', 6)
}
def set_state(self, state):
"""Change animation state"""
if state in self.animations and state != self.current_state:
self.current_state = state
self.frame_idx = 0
print(f"→ State: {state}")
def _drag(self, e):
x = self.root.winfo_x() + e.x - self._d[0]
y = self.root.winfo_y() + e.y - self._d[1]
self.root.geometry(f'+{x}+{y}')
def _animate(self):
"""Animate current state"""
if self.current_state not in self.animations:
self.root.after(100, self._animate)
return
anim = self.animations[self.current_state]
frames = anim['frames']
if frames:
self.label.config(image=frames[self.frame_idx])
self.frame_idx = (self.frame_idx + 1) % len(frames)
delay = int(1000 / anim['fps'])
self.root.after(delay, self._animate)
def show_toast(self, message):
"""Show toast message above pet"""
if self.toast_window:
try:
self.toast_window.destroy()
except:
pass
self.toast_window = None
bubble_info = build_bubble_image(message, max_width=max(180, min(260, self.display_width * 2)))
bubble_pil = bubble_info['image']
bubble_width, bubble_height = bubble_info['size']
tail_x, tail_y = bubble_info['tail_tip']
self.toast_photo = ImageTk.PhotoImage(bubble_pil)
self.toast_window = tk.Toplevel(self.root)
self.toast_window.overrideredirect(True)
self.toast_window.wm_attributes('-topmost', True)
if self.is_windows:
self.toast_window.wm_attributes('-transparentcolor', self.toast_bg_color)
self.toast_window.config(bg=self.toast_bg_color)
toast_label = tk.Label(
self.toast_window,
image=self.toast_photo,
bg=self.toast_bg_color,
bd=0,
highlightthickness=0
)
toast_label.pack()
pet_x = self.root.winfo_x()
pet_y = self.root.winfo_y()
anchor_x = pet_x + int(self.display_width * 0.75)
anchor_y = pet_y
toast_x = anchor_x - tail_x
toast_y = anchor_y - bubble_height
self.toast_window.geometry(f'{bubble_width}x{bubble_height}+{toast_x}+{toast_y}')
self.root.after(3000, self._hide_toast)
print(f"Toast: {message}")
def _hide_toast(self):
"""Hide toast message"""
if self.toast_window:
try:
self.toast_window.destroy()
self.toast_window = None
except:
pass
def _schedule_main(self, fn):
self.root.after(0, fn)
def run(self):
"""Run the application (already in mainloop)"""
pass
def _on_right_click(self, event):
# Build a dynamic menu of all available skins
menu = tk.Menu(self.root, tearoff=0)
for skin_name in SkinLoader.list_skins():
menu.add_command(
label=skin_name,
command=lambda name=skin_name: self._change_skin(name)
)
menu.add_separator()
menu.add_command(label="Quit", command=lambda: (self.root.destroy(), os._exit(0)))
menu.tk_popup(event.x_root, event.y_root)
def _change_skin(self, skin_name):
print(f"Changing skin to: {skin_name}")
self.load_skin(skin_name)
self.current_state = 'idle'
self.frame_idx = 0
else:
from PySide6.QtCore import Qt, QTimer, QPoint
from PySide6.QtGui import QAction, QCursor, QImage, QPixmap
from PySide6.QtWidgets import QApplication, QLabel, QMenu, QWidget
class _LinuxPetLabel(QLabel):
def __init__(self, pet):
super().__init__()
self.pet = pet
self.drag_offset = None
def mousePressEvent(self, event):
if event.button() == Qt.LeftButton:
self.drag_offset = event.globalPosition().toPoint() - self.pet.window.frameGeometry().topLeft()
event.accept()
return
if event.button() == Qt.RightButton:
self.pet._show_context_menu(event.globalPosition().toPoint())
event.accept()
return
super().mousePressEvent(event)
def mouseMoveEvent(self, event):
if self.drag_offset is not None and (event.buttons() & Qt.LeftButton):
self.pet.window.move(event.globalPosition().toPoint() - self.drag_offset)
self.pet._reposition_toast()
event.accept()
return
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event):
if event.button() == Qt.LeftButton:
self.drag_offset = None
super().mouseReleaseEvent(event)
def mouseDoubleClickEvent(self, event):
if event.button() == Qt.LeftButton:
QApplication.instance().quit()
event.accept()
return
super().mouseDoubleClickEvent(event)
class LinuxPet(PetBase):
def __init__(self, skin_name=None):
self.app = QApplication.instance() or QApplication(sys.argv)
self.available_skins = SkinLoader.list_skins()
self.load_skin(skin_name)
screen = self.app.primaryScreen()
screen_geo = screen.availableGeometry() if screen else None
if screen_geo:
x_pos = screen_geo.right() - self.display_width - 72
y_pos = screen_geo.bottom() - self.display_height - 120
else:
x_pos, y_pos = 1200, 700
self.window = QWidget()
self.window.setWindowFlags(
Qt.FramelessWindowHint |
Qt.WindowStaysOnTopHint |
Qt.Tool
)
self.window.setAttribute(Qt.WA_TranslucentBackground, True)
self.window.setAttribute(Qt.WA_ShowWithoutActivating, True)
self.window.resize(self.display_width, self.display_height)
self.window.move(x_pos, y_pos)
self.label = _LinuxPetLabel(self)
self.label.setParent(self.window)
self.label.setGeometry(0, 0, self.display_width, self.display_height)
self.label.setAttribute(Qt.WA_TranslucentBackground, True)
self.label.setStyleSheet('background: transparent;')
self.label.setScaledContents(True)
self.current_state = 'idle'
self.frame_idx = 0
self.toast_window = None
self.toast_label = None
self.toast_pixmap = None
self.anim_timer = QTimer()
self.anim_timer.timeout.connect(self._animate)
self._restart_animation_timer()
self.window.show()
self._start_server()
print(f"✓ Linux PySide6 Pet started at ({x_pos}, {y_pos})")
print(f" Animations: {', '.join(self.animations.keys())}")
def _pil_to_qpixmap(self, pil_img):
buffer = io.BytesIO()
pil_img.save(buffer, format='PNG')
qimage = QImage.fromData(buffer.getvalue(), 'PNG')
return QPixmap.fromImage(qimage)
def load_skin(self, skin_name=None):
available_skins = SkinLoader.list_skins()
if not available_skins:
raise FileNotFoundError(f"No skins found in {SKINS_DIR}")
if skin_name is None or skin_name not in available_skins:
skin_name = available_skins[0]
skin_path = os.path.join(SKINS_DIR, skin_name)
self.skin_config = SkinLoader.load_skin(skin_path)
display_size = self.skin_config.get('size', {})
self.display_width = display_size.get('width', 128)
self.display_height = display_size.get('height', 128)
self.animations = {}
for anim_name, anim_config in self.skin_config['animations'].items():
pil_frames = AnimationLoader.load_sprite_frames(skin_path, anim_config)
qt_frames = []
for frame in pil_frames:
if frame.mode != 'RGBA':
frame = frame.convert('RGBA')
scaled = frame.resize((self.display_width, self.display_height), Image.NEAREST)
qt_frames.append(self._pil_to_qpixmap(scaled))
self.animations[anim_name] = {
'frames': qt_frames,
'fps': anim_config.get('sprite', {}).get('fps', 6)
}
if hasattr(self, 'window'):
self.window.resize(self.display_width, self.display_height)
self.label.setGeometry(0, 0, self.display_width, self.display_height)
self._animate(force=True)
self._reposition_toast()
def _restart_animation_timer(self):
anim = self.animations.get(self.current_state) or next(iter(self.animations.values()))
fps = max(1, anim.get('fps', 6))
self.anim_timer.start(int(1000 / fps))
def _animate(self, force=False):
if self.current_state not in self.animations:
return
anim = self.animations[self.current_state]
frames = anim['frames']
if not frames:
return
if force:
self.frame_idx = 0
self.label.setPixmap(frames[self.frame_idx])
self.frame_idx = (self.frame_idx + 1) % len(frames) self.frame_idx = (self.frame_idx + 1) % len(frames)
delay = int(1000 / anim['fps']) def set_state(self, state):
self.root.after(delay, self._animate) if state in self.animations and state != self.current_state:
self.current_state = state
self.frame_idx = 0
self._restart_animation_timer()
print(f"→ State: {state}")
def show_toast(self, message): def _show_context_menu(self, global_pos):
"""Show toast message above pet""" menu = QMenu(self.window)
if self.toast_window: for skin_name in SkinLoader.list_skins():
try: action = QAction(skin_name, menu)
self.toast_window.destroy() action.triggered.connect(lambda checked=False, name=skin_name: self._change_skin(name))
except: menu.addAction(action)
pass menu.addSeparator()
self.toast_window = None quit_action = QAction('Quit', menu)
quit_action.triggered.connect(QApplication.instance().quit)
menu.addAction(quit_action)
menu.popup(global_pos)
bubble_info = build_bubble_image(message, max_width=max(180, min(260, self.display_width * 2))) def _compute_toast_geometry(self, bubble_width, bubble_height, tail_x, tail_y):
bubble_pil = bubble_info['image'] pet_pos = self.window.frameGeometry().topLeft()
bubble_width, bubble_height = bubble_info['size'] anchor_x = pet_pos.x() + int(self.display_width * 0.75)
tail_x, tail_y = bubble_info['tail_tip'] anchor_y = pet_pos.y() + int(self.display_height * 0.15)
return anchor_x - tail_x, anchor_y - tail_y - bubble_height // 2
self.toast_photo = ImageTk.PhotoImage(bubble_pil) def show_toast(self, message):
if self.toast_window:
self.toast_window = tk.Toplevel(self.root) self.toast_window.close()
self.toast_window.overrideredirect(True)
self.toast_window.wm_attributes('-topmost', True)
self.toast_window.wm_attributes('-transparentcolor', '#00ff01')
self.toast_window.config(bg='#00ff01')
toast_label = tk.Label(
self.toast_window,
image=self.toast_photo,
bg='#00ff01',
bd=0,
highlightthickness=0
)
toast_label.pack()
pet_x = self.root.winfo_x()
pet_y = self.root.winfo_y()
anchor_x = pet_x + int(self.display_width * 0.75)
anchor_y = pet_y
toast_x = anchor_x - tail_x
toast_y = anchor_y - bubble_height
self.toast_window.geometry(f'{bubble_width}x{bubble_height}+{toast_x}+{toast_y}')
self.root.after(3000, self._hide_toast)
print(f"Toast: {message}")
def _hide_toast(self):
"""Hide toast message"""
if self.toast_window:
try:
self.toast_window.destroy()
self.toast_window = None self.toast_window = None
except: self.toast_label = None
pass self.toast_pixmap = None
def _schedule_main(self, fn): bubble_info = build_bubble_image(message, max_width=max(180, min(260, self.display_width * 2)))
self.root.after(0, fn) bubble_pil = bubble_info['image']
bubble_width, bubble_height = bubble_info['size']
tail_x, tail_y = bubble_info['tail_tip']
self.toast_pixmap = self._pil_to_qpixmap(bubble_pil)
def run(self): self.toast_window = QWidget()
"""Run the application (already in mainloop)""" self.toast_window.setWindowFlags(
pass Qt.FramelessWindowHint |
Qt.WindowStaysOnTopHint |
def _on_right_click(self, event): Qt.Tool |
# Build a dynamic menu of all available skins Qt.WindowTransparentForInput
menu = tk.Menu(self.root, tearoff=0)
for skin_name in SkinLoader.list_skins():
menu.add_command(
label=skin_name,
command=lambda name=skin_name: self._change_skin(name)
) )
menu.add_separator() self.toast_window.setAttribute(Qt.WA_TranslucentBackground, True)
menu.add_command(label="Quit", command=lambda: (self.root.destroy(), os._exit(0))) self.toast_window.setAttribute(Qt.WA_ShowWithoutActivating, True)
menu.tk_popup(event.x_root, event.y_root) self.toast_window.resize(bubble_width, bubble_height)
def _change_skin(self, skin_name): self.toast_label = QLabel(self.toast_window)
print(f"Changing skin to: {skin_name}") self.toast_label.setGeometry(0, 0, bubble_width, bubble_height)
self.load_skin(skin_name) self.toast_label.setPixmap(self.toast_pixmap)
self.current_state = 'idle' self.toast_label.setAttribute(Qt.WA_TranslucentBackground, True)
self.frame_idx = 0 self.toast_label.setStyleSheet('background: transparent;')
toast_x, toast_y = self._compute_toast_geometry(bubble_width, bubble_height, tail_x, tail_y)
self.toast_window.move(toast_x, toast_y)
self.toast_window.show()
QTimer.singleShot(3000, self._hide_toast)
print(f"Toast: {message}")
def _reposition_toast(self):
if not self.toast_window:
return
label_pixmap = self.toast_label.pixmap() if self.toast_label else None
if label_pixmap is None:
return
bubble_width = label_pixmap.width()
bubble_height = label_pixmap.height()
toast_x, toast_y = self._compute_toast_geometry(
bubble_width,
bubble_height,
bubble_width // 2,
bubble_height
)
self.toast_window.move(toast_x, toast_y)
def _hide_toast(self):
if self.toast_window:
self.toast_window.close()
self.toast_window = None
self.toast_label = None
self.toast_pixmap = None
def _schedule_main(self, fn):
QTimer.singleShot(0, fn)
def _change_skin(self, skin_name):
print(f"Changing skin to: {skin_name}")
self.load_skin(skin_name)
self.current_state = 'idle'
self.frame_idx = 0
self._restart_animation_timer()
def run(self):
self.app.exec()
if __name__ == '__main__': if __name__ == '__main__':
# Singleton: if port already in use, another instance is running # Singleton: if port already in use, another instance is running
@@ -820,5 +1074,9 @@ if __name__ == '__main__':
if sys.platform == 'darwin': if sys.platform == 'darwin':
pet = MacPet('vita') pet = MacPet('vita')
pet.run() pet.run()
else: elif sys.platform.startswith('win'):
pet = WinPet('vita') pet = WinPet('vita')
else:
pet = LinuxPet('vita')
pet.run()