Compare commits

...
5 Commits
5 changed files with 93 additions and 22 deletions
+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`).
+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
+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:
"""