From a969c91c28c5055ac104297b03c87f0256087c3e Mon Sep 17 00:00:00 2001 From: max Date: Tue, 8 Sep 2026 23:38:00 +0200 Subject: [PATCH] feat(aw): scan both active foreground and visible desktop windows for distractions Previously, fallback window detection only checked user32.GetForegroundWindow(). If the user had YouTube Shorts open in Chrome on a second monitor or behind the terminal, it was ignored. Now check_for_distraction(): 1. Checks the active foreground window first. 2. If not a distraction, scans all visible top-level desktop windows via find_window_by_match(). 3. Immediately wakes the cat from sleeping or sitting if any distraction window is discovered. --- catser/activitywatch.py | 91 +++++++++++++++++++++++++---------------- 1 file changed, 55 insertions(+), 36 deletions(-) diff --git a/catser/activitywatch.py b/catser/activitywatch.py index ccda552..0f87717 100644 --- a/catser/activitywatch.py +++ b/catser/activitywatch.py @@ -169,57 +169,76 @@ class ActivityWatchClient: """Signals the background worker to exit.""" self._running = False - def check_for_distraction(self) -> Optional[DistractionEvent]: - """ - Checks current active window against distraction rules. - Uses ActivityWatch if available, otherwise falls back to native Win32. - """ - app_name = "" - window_title = "" - active_window_info: Optional[WindowInfo] = None - - # 1. Try ActivityWatch - aw_event = self.get_current_window_event() - if aw_event: - app_name = (aw_event.get("app") or "").lower() - window_title = aw_event.get("title") or "" - - # Locate corresponding Win32 HWND for screen coordinates and close button - active_window_info = WindowManager.find_window_by_match( - title_predicate=lambda t: window_title.lower() in t.lower() or t.lower() in window_title.lower(), - process_predicate=lambda p: app_name in p.lower(), - ) - - # 2. Fallback to native Win32 if ActivityWatch is unavailable or HWND not found - if not active_window_info and self.config.auto_fallback_to_win32: - active_window_info = WindowManager.get_foreground_window_info() - if active_window_info: - app_name = active_window_info.process_name.lower() - window_title = active_window_info.title - - if not active_window_info or not window_title: - return None - + def _match_event(self, app_name: str, window_title: str, win_info: WindowInfo) -> Optional[DistractionEvent]: + """Evaluates a window title and process name against distraction rules.""" title_lower = window_title.lower() + app_lower = app_name.lower() - # Check for title keyword matches (e.g. "shorts", "reels", "tiktok") for kw in self.config.distraction_keywords: if kw.lower() in title_lower: return DistractionEvent( app=app_name, title=window_title, matched_rule=f"keyword '{kw}'", - window_info=active_window_info, + window_info=win_info, ) - # Check for application process matches for target_app in self.config.distraction_apps: - if target_app.lower() in app_name: + if target_app.lower() in app_lower: return DistractionEvent( app=app_name, title=window_title, matched_rule=f"app '{target_app}'", - window_info=active_window_info, + window_info=win_info, ) return None + + def check_for_distraction(self) -> Optional[DistractionEvent]: + """ + Checks current active window and visible background windows against distraction rules. + Uses ActivityWatch if available, otherwise falls back to native Win32. + """ + # 1. Try ActivityWatch (non-blocking read of cached event) + aw_event = self.get_current_window_event() + if aw_event: + app_name = (aw_event.get("app") or "").lower() + window_title = aw_event.get("title") or "" + + active_window_info = WindowManager.find_window_by_match( + title_predicate=lambda t: window_title.lower() in t.lower() or t.lower() in window_title.lower(), + process_predicate=lambda p: app_name in p.lower(), + ) + if active_window_info: + matched = self._match_event(app_name, window_title, active_window_info) + if matched: + return matched + + if not self.config.auto_fallback_to_win32: + return None + + # 2. Native Win32: Check foreground window first + fg_info = WindowManager.get_foreground_window_info() + if fg_info and fg_info.title: + matched = self._match_event(fg_info.process_name, fg_info.title, fg_info) + if matched: + return matched + + # 3. Native Win32: If foreground window is not a distraction, check any visible desktop window! + # This catches distraction windows open in the background, on a 2nd monitor, or when user clicks away + def title_matches(t: str) -> bool: + tl = t.lower() + return any(kw.lower() in tl for kw in self.config.distraction_keywords) + + def app_matches(p: str) -> bool: + pl = p.lower() + return any(a.lower() in pl for a in self.config.distraction_apps) + + matched_window = WindowManager.find_window_by_match( + title_predicate=title_matches if self.config.distraction_keywords else None, + process_predicate=app_matches if self.config.distraction_apps else None, + ) + if matched_window and matched_window.title: + return self._match_event(matched_window.process_name, matched_window.title, matched_window) + + return None