Compare commits

..
10 Commits
10 changed files with 172 additions and 26 deletions
+1 -1
View File
@@ -8,8 +8,8 @@ env/
venv/
.venv/
build/
develop-eggs/
dist/
release/
downloads/
eggs/
.eggs/
+39
View File
@@ -0,0 +1,39 @@
# -*- mode: python ; coding: utf-8 -*-
a = Analysis(
['run_catser.py'],
pathex=[],
binaries=[],
datas=[('catser/assets', 'catser/assets')],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
optimize=0,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
a.binaries,
a.datas,
[],
name='Catser',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=True,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=['catser.ico'],
)
+22
View File
@@ -0,0 +1,22 @@
MIT License
Copyright (c) 2026 Catser Contributors
Sprite assets courtesy of Workcat (https://workcat.app)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+5 -4
View File
@@ -34,10 +34,11 @@ A Windows desktop cat companion inspired by [Workcat](https://workcat.app/en/#ho
- Cat autonomously performs parabolic leaps onto window ledges, walks along them, sits, naps, and hops back down.
- Dynamic surface tracking: If an underlying window moves or closes while the cat is resting on it, the cat wakes up and falls under gravity to the next surface or floor.
- 🖥️ **Multi-Monitor Roaming & Elevation Navigation**:
- Full virtual desktop support spanning all connected displays with arbitrary resolutions, positions, and DPI.
- Automatically calculates distinct taskbar/work-area floors for each screen.
- Seamlessly handles elevation differences (e.g. stepping off higher floors into gravity falls, or leaping up steep monitor steps).
- 🖥️ **Dynamic Multi-Monitor & Resolution Detection**:
- Full virtual desktop support spanning all connected displays with arbitrary resolutions, positions, and DPI scaling.
- **Live Hotplug Detection**: Listens to Win32 `WM_DISPLAYCHANGE` and `WM_SETTINGCHANGE` system events (with a 1.5s background check) to dynamically detect when monitors are connected, disconnected, rearranged, or when display resolution/scaling changes.
- Automatically recalculates screen bounds and taskbar floors in real-time, clamping the cat into valid active displays so it never gets stranded off-screen.
- Seamlessly navigates elevation differences between monitors (e.g. stepping off higher floors into gravity falls, or leaping up steep monitor steps).
- 🪟 **High-Performance Transparent Overlay**:
- Built with Win32 Layered Windows (`WS_EX_LAYERED | WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE`).
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+1
View File
@@ -55,6 +55,7 @@ class CatserApp:
on_mouse_down=self.controller.on_mouse_down,
on_mouse_move=self.controller.on_mouse_move,
on_mouse_up=self.controller.on_mouse_up,
on_display_change=self.controller.refresh_display_bounds,
)
# 4. ActivityWatch Initial Ping
+16 -2
View File
@@ -14,6 +14,7 @@ Assets managed:
"""
import os
import sys
import re
import json
import math
@@ -48,9 +49,12 @@ def _download_file(url: str, dest_path: Path) -> bool:
Downloads a file with custom User-Agent to avoid Cloudflare 403 blocks.
Returns True on success, False otherwise.
"""
dest_path.parent.mkdir(parents=True, exist_ok=True)
if dest_path.exists() and dest_path.stat().st_size > 0:
return True
try:
dest_path.parent.mkdir(parents=True, exist_ok=True)
except Exception:
pass
try:
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
@@ -84,7 +88,14 @@ class AssetsManager:
"""
def __init__(self, assets_dir: Optional[Path] = None, cat_width: int = 144, config: Optional[Config] = None):
self.assets_dir = assets_dir or (Path(__file__).parent / "assets")
if assets_dir:
self.assets_dir = assets_dir
elif getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
mei_catser = Path(sys._MEIPASS) / "catser" / "assets"
self.assets_dir = mei_catser if mei_catser.exists() else (Path(sys._MEIPASS) / "assets")
else:
self.assets_dir = Path(__file__).parent / "assets"
self.config = config or Config(cat_width=cat_width)
self.cat_width = self.config.cat_width
self.cat_height = self.config.cat_height
@@ -106,7 +117,10 @@ class AssetsManager:
def initialize(self) -> None:
"""Downloads all missing assets and prepares raw frames."""
try:
self.assets_dir.mkdir(parents=True, exist_ok=True)
except Exception:
pass
self._ensure_assets_downloaded()
self._load_raw_assets()
+65 -18
View File
@@ -84,24 +84,10 @@ class CatController:
self.config = config
self.assets = assets
# Primary monitor work area (excludes taskbar)
work_area = WindowManager.get_work_area()
self.screen_left = work_area[0]
self.screen_top = work_area[1]
self.screen_right = work_area[2]
self.screen_bottom = work_area[3]
# Full virtual desktop spanning ALL monitors used for roaming, platforming,
# and dragging so the cat can freely explore secondary displays.
vd = WindowManager.get_virtual_desktop_bounds()
self.vd_left = vd[0]
self.vd_top = vd[1]
self.vd_right = vd[2]
self.vd_bottom = vd[3]
# Horizontal roaming boundaries across all monitors
self.min_x = float(self.vd_left + 15)
self.max_x = float(self.vd_right - self.config.cat_width - 15)
# Dynamic display topology signature & periodic checker
self._current_display_signature = None
self.next_display_check_time = time.time() + 1.5
self.refresh_display_bounds(force=True)
# Position & motion
self.x = float(self.screen_left + 100)
@@ -155,6 +141,62 @@ class CatController:
self.walk_speed_px = (WALK_SPEED / config.cat_width) * (config.cat_width * 5.0)
self.run_speed_px = (RUN_SPEED / config.cat_width) * (config.cat_width * 4.0)
def refresh_display_bounds(self, force: bool = False) -> bool:
"""
Dynamically refreshes monitor geometries, virtual desktop boundaries,
and clamps cat position if display topology or resolution changed.
Returns True if a display change occurred, False otherwise.
"""
new_signature = WindowManager.get_display_topology_signature()
if not force and new_signature == self._current_display_signature:
return False
self._current_display_signature = new_signature
# Update primary monitor work area (excludes taskbar)
work_area = WindowManager.get_work_area()
self.screen_left = work_area[0]
self.screen_top = work_area[1]
self.screen_right = work_area[2]
self.screen_bottom = work_area[3]
# Update full virtual desktop spanning all monitors
vd = WindowManager.get_virtual_desktop_bounds()
self.vd_left = vd[0]
self.vd_top = vd[1]
self.vd_right = vd[2]
self.vd_bottom = vd[3]
# Update horizontal roaming boundaries
self.min_x = float(self.vd_left + 15)
self.max_x = float(self.vd_right - self.config.cat_width - 15)
monitors = WindowManager.get_monitors()
logger.info(
f"Dynamic display configuration updated: {len(monitors)} monitor(s) detected, "
f"virtual desktop=({self.vd_left}, {self.vd_top}, {self.vd_right}, {self.vd_bottom}), "
f"primary work area={work_area}"
)
# Ensure the cat is not stranded outside newly active bounds
if hasattr(self, "x") and hasattr(self, "y"):
prev_x = self.x
self.x = max(self.min_x, min(self.max_x, self.x))
# Re-evaluate floor at current position
new_floor = WindowManager.get_floor_y_at(self.x + self.config.cat_width * 0.5, self.config.cat_height)
self.floor_y = new_floor
# If walking or resting on floor, adjust Y to new floor
if hasattr(self, "current_ledge") and not self.current_ledge:
if hasattr(self, "state") and self.state in (CatState.WALK, CatState.SIT, CatState.SLEEP, CatState.RELEASE):
self.y = new_floor
if prev_x != self.x:
logger.info(f"Clamped cat X from {prev_x} to {self.x} due to display boundary change")
return True
# ==========================================================================
# State Transitions & Targeting
# ==========================================================================
@@ -348,6 +390,11 @@ class CatController:
face_type = "happy" if self.state == CatState.HAPPY else ("blink" if self.is_blinking else "open")
# Dynamic check for monitor addition/removal, resolution or work-area change
if now >= self.next_display_check_time:
self.next_display_check_time = now + 1.5
self.refresh_display_bounds()
# ----------------------------------------------------------------------
# State: WALK (roaming desktop floors and window ledges)
# ----------------------------------------------------------------------
+12
View File
@@ -76,6 +76,8 @@ WM_LBUTTONDOWN = 0x0201
WM_LBUTTONUP = 0x0202
WM_MOUSEMOVE = 0x0200
WM_NCHITTEST = 0x0084
WM_DISPLAYCHANGE = 0x007E
WM_SETTINGCHANGE = 0x001A
# Cursors
IDC_ARROW = 32512
@@ -187,6 +189,7 @@ class OverlayWindow:
on_mouse_down: Optional[Callable[[int, int], None]] = None,
on_mouse_move: Optional[Callable[[int, int], None]] = None,
on_mouse_up: Optional[Callable[[int, int], None]] = None,
on_display_change: Optional[Callable[[], None]] = None,
):
self.width = width
self.height = height
@@ -196,6 +199,7 @@ class OverlayWindow:
self.on_mouse_down = on_mouse_down
self.on_mouse_move = on_mouse_move
self.on_mouse_up = on_mouse_up
self.on_display_change = on_display_change
self.hwnd: Optional[int] = None
self._wndproc_ref = None # Prevent garbage collection of callback
@@ -352,6 +356,14 @@ class OverlayWindow:
self.on_mouse_up(pos.x, pos.y)
return 0
elif msg in (WM_DISPLAYCHANGE, WM_SETTINGCHANGE):
if self.on_display_change:
try:
self.on_display_change()
except Exception as e:
logger.error(f"Error in on_display_change callback: {e}")
return 0
elif msg == WM_DESTROY:
user32.PostQuitMessage(0)
return 0
+10
View File
@@ -349,6 +349,16 @@ class WindowManager:
user32.EnumDisplayMonitors(0, None, MONITORENUMPROC(enum_mon_proc), 0)
return monitors
@classmethod
def get_display_topology_signature(cls) -> tuple:
"""
Returns a hashable signature representing current displays, resolutions, and work areas.
Used to detect dynamic monitor connection, disconnection, or resolution adjustments.
"""
monitors = cls.get_monitors()
vd = cls.get_virtual_desktop_bounds()
return (vd, tuple((m.handle, m.is_primary, m.rect, m.work_area) for m in monitors))
@classmethod
def get_floor_y_at(cls, x: float, cat_height: int) -> float:
"""