Compare commits
5
Commits
59ba4b988f
...
c8f12b26c1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8f12b26c1 | ||
|
|
449c6bac9a | ||
|
|
8aeb5360f6 | ||
|
|
3afd197027 | ||
|
|
0033fbce9b |
@@ -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`).
|
||||
|
||||
@@ -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
@@ -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)
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user